use super::{
TRANSCRIPT_VIEWPORT_OVERSCAN_ROWS,
projection::{
ProjectedTranscriptCard, TranscriptCard, TranscriptCardChild, TranscriptCardRole,
TranscriptCardStatus, project_card_spans, project_card_spans_reverse_from, project_cards,
},
};
use crate::{
output::ActivityStatus,
rendering::DisplayRole,
tui::{
normalize_inline_separators,
state::{MissionControlState, activity_status_mark},
theme::MissionControlTheme,
transcript,
},
};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
#[cfg(test)]
use std::cell::Cell;
use std::{cell::Ref, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[cfg(test)]
thread_local! {
static VISUAL_BLOCK_REBUILDS: Cell<usize> = const { Cell::new(0) };
static VISUAL_ROW_MEASUREMENTS: Cell<usize> = const { Cell::new(0) };
static BODY_LINES_FORMATTED: Cell<usize> = const { Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn take_body_lines_formatted_for_test() -> usize {
BODY_LINES_FORMATTED.with(|count| count.replace(0))
}
#[cfg(test)]
pub(crate) fn reset_visual_cache_counters_for_test() {
VISUAL_BLOCK_REBUILDS.with(|count| count.set(0));
VISUAL_ROW_MEASUREMENTS.with(|count| count.set(0));
}
#[cfg(test)]
pub(crate) fn visual_block_rebuild_count_for_test() -> usize {
VISUAL_BLOCK_REBUILDS.with(Cell::get)
}
#[cfg(test)]
pub(crate) fn visual_row_measurement_count_for_test() -> usize {
VISUAL_ROW_MEASUREMENTS.with(Cell::get)
}
#[cfg(test)]
pub(crate) fn visual_block_width_count_for_test(state: &MissionControlState) -> usize {
state.transcript_cache.borrow().visual_blocks.len()
}
#[cfg(test)]
pub(crate) fn visual_width_cache_presence_for_test(
state: &MissionControlState,
width: u16,
) -> [bool; 4] {
let width = width.max(1);
let cache = state.transcript_cache.borrow();
[
cache.visual_rows.contains_key(&width),
cache.scrollbar_rows.contains_key(&width),
cache.visual_lines.contains_key(&width),
cache.visual_blocks.contains_key(&width),
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TranscriptHitTarget {
SelectTask {
batch_id: crate::output::ActivityId,
task_id: crate::output::ActivityId,
},
OpenTranscript {
batch_id: crate::output::ActivityId,
task_id: crate::output::ActivityId,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptHitRegion {
pub(crate) columns: Range<u16>,
pub(crate) target: TranscriptHitTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptVisualLine {
pub(crate) line: Line<'static>,
pub(crate) copy_text: String,
pub(crate) copy_byte_range: Option<(usize, usize)>,
pub(crate) copyable: bool,
pub(crate) copy_continuation: bool,
pub(crate) hit_regions: Vec<TranscriptHitRegion>,
pub(crate) visual_copy_column_offset: u16,
pub(crate) visual_copy_byte_boundaries: Vec<usize>,
pub(crate) status_indicators: Vec<(u16, Option<crate::output::ActivityId>)>,
}
pub(super) 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);
for (line, &rows) in block.lines.iter().zip(&block.line_rows) {
if let Some(result) = 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_card_visual_lines(
state,
&mut lines,
&card,
0,
state.is_transcript_focused(),
width,
false,
);
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);
lines.extend(block.lines.iter().cloned());
}
}
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);
let _ = cached_visual_lines_ref(state, width);
state
.transcript_cache
.borrow()
.visual_lines
.get(&width)
.map(|cached| cached.rows)
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TranscriptScrollbarMetrics {
pub(crate) content_length: usize,
pub(crate) position: usize,
pub(crate) viewport_length: usize,
}
impl TranscriptScrollbarMetrics {
fn new(total_rows: usize, position: usize, viewport_length: usize) -> Self {
let max_position = total_rows.saturating_sub(viewport_length);
Self {
content_length: max_position.saturating_add(1),
position: position.min(max_position),
viewport_length,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct VisibleTranscriptLines {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) scroll_offset: usize,
pub(crate) scrollbar: TranscriptScrollbarMetrics,
}
#[derive(Debug, Clone)]
pub(crate) struct VisibleTranscriptVisualLines {
pub(crate) lines: Vec<TranscriptVisualLine>,
pub(crate) scroll_offset: usize,
pub(crate) scrollbar: TranscriptScrollbarMetrics,
#[cfg(test)]
pub(crate) has_overflow_above: bool,
#[cfg(test)]
pub(crate) visual_start_row: usize,
}
fn annotate_copy_byte_ranges(lines: &mut [TranscriptVisualLine]) {
let mut byte_base = 0;
let mut copy_line_seen = false;
for line in lines {
line.copy_byte_range = advance_copy_byte_range(line, &mut byte_base, &mut copy_line_seen);
}
}
fn advance_copy_byte_range(
line: &TranscriptVisualLine,
byte_base: &mut usize,
copy_line_seen: &mut bool,
) -> Option<(usize, usize)> {
if !line.copyable {
return None;
}
if !line.copy_continuation && *copy_line_seen {
*byte_base = byte_base.saturating_add(1);
}
*copy_line_seen = true;
let start = *byte_base;
*byte_base = byte_base.saturating_add(line.copy_text.len());
Some((start, *byte_base))
}
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,
#[cfg(test)]
visual_start_row: visible_start,
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) {
let block = cached_card_visual_block(state, &span, width);
for (line, &rows) in block.lines.iter().zip(&block.line_rows) {
if current_row >= stop_row {
return VisibleTranscriptVisualLines {
#[cfg(test)]
has_overflow_above: visible_start > 0,
#[cfg(test)]
visual_start_row: visible_start,
lines: visible_lines,
scroll_offset,
scrollbar: TranscriptScrollbarMetrics::new(
current_row,
visible_start,
usize::from(visible_rows),
),
};
}
let copy_byte_range =
advance_copy_byte_range(line, &mut byte_base, &mut copy_line_seen);
let next_row = current_row.saturating_add(rows);
if next_row > visible_start && current_row < visible_end {
if visible_lines.is_empty() {
scroll_offset = visible_start.saturating_sub(current_row);
}
let mut line = line.clone();
line.copy_byte_range = copy_byte_range;
visible_lines.push(line);
}
current_row = next_row;
}
}
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),
),
#[cfg(test)]
visual_start_row: visible_start,
}
}
pub(crate) fn visible_scrollback_visual_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
scrollback_rows: usize,
) -> VisibleTranscriptVisualLines {
let width = width.max(1);
let visible_rows = usize::from(visible_rows);
let target_rows = visible_rows
.saturating_add(scrollback_rows)
.saturating_add(usize::from(TRANSCRIPT_VIEWPORT_OVERSCAN_ROWS));
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()
})
};
let mut collected_rows = index.cumulative_rows.last().copied().unwrap_or(1);
if collected_rows < target_rows {
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.blocks.push(block);
index.cumulative_rows.push(collected_rows);
if collected_rows >= target_rows {
break;
}
}
}
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();
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 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;
'blocks: for block in blocks.into_iter().rev() {
debug_assert_eq!(block.lines.len(), block.line_rows.len());
let next_block_row = current_row.saturating_add(block.rows);
if next_block_row <= visible_start {
current_row = next_block_row;
continue;
}
for (visual_line, &rows) in block.lines.iter().zip(&block.line_rows) {
let next_row = current_row.saturating_add(rows);
if next_row <= visible_start {
current_row = next_row;
continue;
}
if current_row >= visible_end {
break 'blocks;
}
if visible_lines.is_empty() {
scroll_offset = visible_start.saturating_sub(current_row);
}
visible_lines.push(visual_line.clone());
current_row = next_row;
}
}
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),
},
#[cfg(test)]
visual_start_row: visible_start,
}
}
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 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);
}
}
#[cfg(test)]
VISUAL_BLOCK_REBUILDS.with(|count| count.set(count.get().saturating_add(1)));
let lines =
card_visual_lines_uncached(state, &span.card, entry_index, active, width, prepend_blank);
let line_rows = lines
.iter()
.map(|line| visual_line_rows(line, width))
.collect::<Vec<_>>();
let rows = line_rows.iter().copied().sum();
let block = Arc::new(transcript::CachedTranscriptVisualBlock {
content_marker: span.content_marker,
theme_revision,
active,
prepend_blank,
subagent_card_rows,
lines,
line_rows,
rows,
});
state
.transcript_cache
.borrow_mut()
.insert_visual_block(width, cache_index, Arc::clone(&block));
block
}
pub(crate) fn activity_card_visual_lines(
state: &MissionControlState,
node: &crate::tui::activity::ActivityNode,
width: u16,
prepend_blank: bool,
) -> Vec<TranscriptVisualLine> {
let card = super::projection::project_activity_card(state, node);
card_visual_lines_uncached(state, &card, usize::MAX, false, width.max(1), prepend_blank)
}
pub(crate) fn activity_viewer_notice_visual_line(
state: &MissionControlState,
width: u16,
text: impl Into<String>,
) -> TranscriptVisualLine {
let width = width.max(1);
let style = state.theme.muted(false).bg(state.theme.surface_panel());
TranscriptVisualLine {
status_indicators: Vec::new(),
line: wide_body_line(vec![Span::styled(text.into(), style)], width, style),
copy_text: String::new(),
copy_byte_range: None,
copyable: false,
copy_continuation: false,
visual_copy_column_offset: 0,
visual_copy_byte_boundaries: Vec::new(),
hit_regions: Vec::new(),
}
}
fn card_visual_lines_uncached(
state: &MissionControlState,
card: &TranscriptCard,
entry_index: usize,
active: bool,
width: u16,
prepend_blank: bool,
) -> Vec<TranscriptVisualLine> {
let mut lines = Vec::new();
append_card_visual_lines(
state,
&mut lines,
card,
entry_index,
active,
width,
prepend_blank,
);
lines
}
fn append_card_visual_lines(
state: &MissionControlState,
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
entry_index: usize,
active: bool,
width: u16,
prepend_blank: bool,
) {
if prepend_blank {
lines.push(transcript_gap_visual_line(width, state.theme));
}
append_wide_card_lines(state, lines, card, entry_index, active, width);
}
pub(crate) fn rendered_line_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
pub(crate) fn visual_line_rows(line: &TranscriptVisualLine, width: u16) -> usize {
#[cfg(test)]
VISUAL_ROW_MEASUREMENTS.with(|count| count.set(count.get().saturating_add(1)));
transcript::ratatui_wrapped_line_rows(vec![line.line.clone()], width)
}
fn card_header_title(card: &TranscriptCard) -> String {
if card.role == TranscriptCardRole::SubagentBatch {
return subagent_card_header_text(card);
}
let title = if card.role == TranscriptCardRole::Tool {
format!("Tool • {}", card.title)
} else {
card.title.clone()
};
normalize_inline_separators(&title)
}
fn subagent_card_header_text(card: &TranscriptCard) -> String {
let Some(subagent_card) = &card.subagent_card else {
return "Subagents".to_string();
};
let mut parts = vec![format!(
"Subagents • {}/{} settled",
subagent_card.done + subagent_card.failed,
subagent_card.total
)];
if subagent_card.running > 0 {
parts.push(format!("RUN{}", subagent_card.running));
}
if subagent_card.failed > 0 {
parts.push(format!("FAIL{}", subagent_card.failed));
}
let waiting = subagent_card
.total
.saturating_sub(subagent_card.done + subagent_card.failed + subagent_card.running);
if waiting > 0 {
parts.push(format!("WAIT{waiting}"));
}
parts.join(" • ")
}
fn transcript_timestamp_text(state: &MissionControlState, entry_index: usize) -> Option<String> {
state
.transcript_timestamp(entry_index)
.map(|timestamp| timestamp.format("%H:%M:%S").to_string())
}
fn timestamp_header_style(
role: TranscriptCardRole,
active: bool,
theme: MissionControlTheme,
) -> Style {
theme
.muted(active)
.bg(card_header_background(role, theme))
.add_modifier(Modifier::DIM)
}
fn append_wide_card_lines(
state: &MissionControlState,
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
entry_index: usize,
active: bool,
width: u16,
) {
let theme = state.theme;
let timestamp = transcript_timestamp_text(state, entry_index);
let body_style =
role_body_style(card.role, active, theme).bg(card_body_background(card.role, theme));
if card.role == TranscriptCardRole::UserPrompt {
append_user_prompt_lines(
lines,
card,
body_style,
width,
timestamp.as_deref(),
active,
theme,
);
return;
}
let label_style =
role_label_style(card.role, active, theme).bg(card_header_background(card.role, theme));
let title = card_header_title(card);
let status = display_status_mark(card);
let display_width = usize::from(width);
let timestamp_text = timestamp
.as_deref()
.map(|timestamp| truncate_to_display_width(timestamp, display_width))
.filter(|timestamp| !timestamp.is_empty());
let timestamp_width = timestamp_text
.as_deref()
.map(UnicodeWidthStr::width)
.unwrap_or(0);
let timestamp_start = display_width.saturating_sub(timestamp_width);
let prefix_limit = if timestamp_width == 0 {
display_width
} else {
timestamp_start.saturating_sub(usize::from(timestamp_start > 0))
};
let leading_width = usize::from(prefix_limit > 0);
let content_width = prefix_limit.saturating_sub(leading_width);
let status_width = UnicodeWidthStr::width(status);
let status_visible = !status.is_empty() && content_width >= status_width;
let title_width = if status_visible {
content_width
.saturating_sub(status_width)
.saturating_sub(usize::from(content_width > status_width))
} else {
content_width
};
let title_text = truncate_to_display_width(&title, title_width);
let header_background = card_header_background(card.role, theme);
let copy_text = if timestamp_width > 0 {
let mut copy_text = String::new();
if status_visible {
copy_text.push_str(status);
if content_width > status_width {
copy_text.push(' ');
}
}
copy_text.push_str(&title_text);
copy_text
} else if status.is_empty() {
title.clone()
} else {
format!("{status} {title}")
};
let mut header_spans = Vec::new();
if leading_width > 0 {
header_spans.push(Span::styled(
" ".repeat(leading_width),
Style::default().bg(header_background),
));
}
if status_visible {
header_spans.push(Span::styled(
status.to_string(),
status_header_style(card.status, theme).bg(header_background),
));
if content_width > status_width {
header_spans.push(Span::styled(" ", label_style));
}
}
if !title_text.is_empty() {
header_spans.push(Span::styled(title_text, label_style));
}
let used = spans_display_width(&header_spans);
if timestamp_width > 0 {
let padding = timestamp_start.saturating_sub(used);
if padding > 0 {
header_spans.push(Span::styled(
" ".repeat(padding),
Style::default().bg(header_background),
));
}
header_spans.push(Span::styled(
timestamp_text.expect("timestamp width implies timestamp text"),
timestamp_header_style(card.role, active, theme),
));
} else {
header_spans = pad_full_width_spans(header_spans, width);
}
push_styled_blank(lines, width, header_background);
lines.push(TranscriptVisualLine {
status_indicators: if status_visible
&& matches!(
card.status,
TranscriptCardStatus::Running | TranscriptCardStatus::Success
) {
vec![(
display_width_to_u16(leading_width),
state
.transcript
.activity_links()
.get(&card.id.entry_index)
.map(|link| link.activity_id.clone()),
)]
} else {
Vec::new()
},
line: Line::from(header_spans),
copy_text,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: display_width_to_u16(leading_width),
visual_copy_byte_boundaries: Vec::new(),
hit_regions: Vec::new(),
});
push_styled_blank(lines, width, header_background);
push_body_blank(lines, card, width, theme);
append_card_body_lines(lines, state, card, active, body_style, width);
push_body_blank(lines, card, width, theme);
}
fn push_body_blank(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
width: u16,
theme: MissionControlTheme,
) {
push_styled_blank(lines, width, card_body_background(card.role, theme));
}
fn push_styled_blank(lines: &mut Vec<TranscriptVisualLine>, width: u16, background: Color) {
lines.push(TranscriptVisualLine {
status_indicators: Vec::new(),
line: Line::from(Span::styled(
" ".repeat(usize::from(width)),
Style::default().bg(background),
)),
copy_text: String::new(),
copy_byte_range: None,
copyable: false,
copy_continuation: false,
visual_copy_column_offset: 0,
visual_copy_byte_boundaries: Vec::new(),
hit_regions: Vec::new(),
});
}
fn horizontal_padding(width: u16) -> (usize, usize) {
if width >= 3 { (1, 1) } else { (0, 0) }
}
fn user_prompt_horizontal_padding(width: u16) -> usize {
usize::from(width.saturating_sub(1) / 2).min(2)
}
fn body_wrap_width(width: u16) -> usize {
let (left, right) = horizontal_padding(width);
usize::from(width)
.saturating_sub(left)
.saturating_sub(right)
.max(1)
}
fn body_line_copy_offset(width: u16, extra: u16) -> u16 {
let (left, _) = horizontal_padding(width);
display_width_to_u16(left.saturating_add(usize::from(extra)))
}
fn append_display_body_line(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
line: &crate::rendering::DisplayLine,
theme: MissionControlTheme,
body_style: Style,
width: u16,
) {
#[cfg(test)]
BODY_LINES_FORMATTED.with(|count| count.set(count.get() + 1));
let mut spans = Vec::with_capacity(line.spans.len().saturating_add(1));
let assistant = card.role == TranscriptCardRole::Assistant;
if assistant {
spans.push(AssistantVisualSpan::styled(
" ",
theme.pane().bg(card_body_background(card.role, theme)),
None,
));
}
spans.extend(line.spans.iter().enumerate().map(|(index, span)| {
AssistantVisualSpan::styled(
if !assistant && index == 0 {
format!(" {}", span.text)
} else {
span.text.clone()
},
if span.role == DisplayRole::Plain && !assistant {
body_style
} else {
theme
.display_role(span.role)
.bg(card_body_background(card.role, theme))
},
Some(span.role),
)
}));
push_wide_assistant_spans(lines, card, spans, true, width, body_style);
}
fn append_cached_streaming_body(
lines: &mut Vec<TranscriptVisualLine>,
state: &MissionControlState,
card: &TranscriptCard,
active: bool,
body_style: Style,
width: u16,
) -> bool {
if card.role != TranscriptCardRole::Assistant
|| card.status != TranscriptCardStatus::Running
|| card.body.len() > transcript::MAX_STREAMING_CACHE_BYTES
|| !card.children.is_empty()
|| card.id.entry_index == usize::MAX
{
return false;
}
let entry_index = state
.transcript_absolute_index(card.id.entry_index)
.unwrap_or(card.id.entry_index);
let mut retained = {
let mut cache = state.transcript_cache.borrow_mut();
cache.touch_visual_width(width);
let Some(streaming) = cache
.streaming
.as_mut()
.filter(|cached| cached.entry_index == entry_index)
else {
return false;
};
if card
.body
.len()
.saturating_add(card.body_lines.len().saturating_mul(usize::from(width)))
> transcript::MAX_STREAMING_CACHE_BYTES
{
streaming.visual_lines.remove(&width);
return false;
}
streaming.visual_lines.remove(&width)
}
.filter(|cached| {
cached.theme_revision == state.theme_revision()
&& cached.active == active
&& card.body.starts_with(&cached.completed_prefix)
})
.unwrap_or_else(|| transcript::CachedStreamingVisualLines {
completed_prefix: String::new(),
theme_revision: state.theme_revision(),
active,
lines: Vec::new(),
});
let previous_lines = retained
.completed_prefix
.bytes()
.filter(|byte| *byte == b'\n')
.count();
let completed_end = card.body.rfind('\n').map_or(0, |index| index + 1);
let completed_lines = card.body[..completed_end]
.bytes()
.filter(|byte| *byte == b'\n')
.count();
for line in &card.body_lines[previous_lines..completed_lines] {
append_display_body_line(
&mut retained.lines,
card,
line,
state.theme,
body_style,
width,
);
}
retained
.completed_prefix
.push_str(&card.body[retained.completed_prefix.len()..completed_end]);
lines.extend(retained.lines.iter().cloned());
for line in &card.body_lines[completed_lines..] {
append_display_body_line(lines, card, line, state.theme, body_style, width);
}
state
.transcript_cache
.borrow_mut()
.streaming
.as_mut()
.expect("streaming projection")
.visual_lines
.insert(width, retained);
true
}
fn append_card_body_lines(
lines: &mut Vec<TranscriptVisualLine>,
state: &MissionControlState,
card: &TranscriptCard,
active: bool,
body_style: Style,
width: u16,
) {
let theme = state.theme;
if card.role == TranscriptCardRole::SubagentBatch {
append_subagent_card_body_lines(lines, state, card, active, width, body_style);
return;
}
for metadata in &card.metadata {
push_text_line(
lines,
card,
format!(" {metadata}"),
theme.dim(active).bg(card_body_background(card.role, theme)),
false,
width,
body_style,
);
}
if append_cached_streaming_body(lines, state, card, active, body_style, width) {
return;
}
if !card.body_lines.is_empty() {
for line in &card.body_lines {
append_display_body_line(lines, card, line, theme, body_style, width);
}
} else {
for body_line in card.body.lines() {
push_text_line(
lines,
card,
format!(" {body_line}"),
body_style,
true,
width,
body_style,
);
}
}
for child in &card.children {
let child_status = status_mark(child.status);
let label = if child_status.is_empty() {
format!(" - {}", child.label)
} else {
format!(" - {child_status} {}", child.label)
};
push_text_line(
lines,
card,
label,
theme
.transcript_tool_body()
.bg(card_body_background(card.role, theme)),
true,
width,
body_style,
);
}
}
fn append_user_prompt_lines(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
body_style: Style,
width: u16,
timestamp: Option<&str>,
active: bool,
theme: MissionControlTheme,
) {
push_body_blank(lines, card, width, theme);
let timestamp_style = theme
.muted(active)
.bg(card_body_background(card.role, theme))
.add_modifier(Modifier::DIM);
let body_lines = if card.body.is_empty() {
vec![""]
} else {
card.body.lines().collect::<Vec<_>>()
};
for (line_index, body_line) in body_lines.into_iter().enumerate() {
let first = line_index == 0;
let mut spans = Vec::new();
if first {
if let Some(timestamp) = timestamp {
spans.push(UserPromptVisualSpan::styled(
timestamp,
timestamp_style,
false,
));
spans.push(UserPromptVisualSpan::styled(" | ", timestamp_style, false));
}
spans.push(UserPromptVisualSpan::styled("User: ", body_style, true));
}
if !body_line.is_empty() {
spans.push(UserPromptVisualSpan::styled(body_line, body_style, true));
}
let padding = user_prompt_horizontal_padding(width);
let wrap_width = usize::from(width).saturating_sub(2 * padding).max(1);
let wrapped = wrap_user_prompt_spans_to_width(spans, wrap_width);
let mut copy_line_seen = false;
for row in wrapped {
let row_has_copy = row.iter().any(|span| span.copyable);
let force_copyable = !first && !row_has_copy;
let copyable = row_has_copy || force_copyable;
push_user_prompt_row(
lines,
row,
width,
body_style,
force_copyable,
copy_line_seen,
);
if copyable {
copy_line_seen = true;
}
}
}
push_body_blank(lines, card, width, theme);
}
struct UserPromptVisualSpan {
display_text: String,
copy_text: String,
style: Style,
copyable: bool,
}
impl UserPromptVisualSpan {
fn styled(text: impl Into<String>, style: Style, copyable: bool) -> Self {
let text = text.into();
Self {
copy_text: if copyable {
text.clone()
} else {
String::new()
},
display_text: text,
style,
copyable,
}
}
fn with_copy_text(
display_text: impl Into<String>,
copy_text: impl Into<String>,
style: Style,
copyable: bool,
) -> Self {
Self {
display_text: display_text.into(),
copy_text: copy_text.into(),
style,
copyable,
}
}
}
fn copy_byte_boundaries_for_span(display_text: &str, copy_text: &str) -> Vec<usize> {
if display_text != copy_text {
let display_width = UnicodeWidthStr::width(display_text);
let mut boundaries = vec![0; display_width.saturating_add(1)];
if let Some(end) = boundaries.last_mut() {
*end = copy_text.len();
}
return boundaries;
}
let mut boundaries = vec![0];
let mut copy_byte = 0usize;
let mut saw_positive_width = false;
for grapheme in display_text.graphemes(true) {
let width = UnicodeWidthStr::width(grapheme);
if width == 0 {
copy_byte = copy_byte.saturating_add(grapheme.len());
if saw_positive_width && let Some(end) = boundaries.last_mut() {
*end = copy_byte;
}
continue;
}
let grapheme_start = if saw_positive_width {
copy_byte
} else {
boundaries.last().copied().unwrap_or_default()
};
for _ in 1..width {
boundaries.push(grapheme_start);
}
copy_byte = copy_byte.saturating_add(grapheme.len());
boundaries.push(copy_byte);
saw_positive_width = true;
}
if !saw_positive_width && let Some(end) = boundaries.last_mut() {
*end = copy_byte;
}
boundaries
}
fn append_copy_byte_boundaries(
boundaries: &mut Vec<usize>,
span_boundaries: &[usize],
copy_byte_base: usize,
) {
let Some((first, rest)) = span_boundaries.split_first() else {
return;
};
let first = copy_byte_base.saturating_add(*first);
if let Some(last) = boundaries.last_mut() {
*last = first;
} else {
boundaries.push(first);
}
boundaries.extend(
rest.iter()
.map(|boundary| copy_byte_base.saturating_add(*boundary)),
);
}
fn push_user_prompt_row(
lines: &mut Vec<TranscriptVisualLine>,
row: Vec<UserPromptVisualSpan>,
width: u16,
body_style: Style,
force_copyable: bool,
copy_continuation: bool,
) {
let mut spans = Vec::with_capacity(row.len());
let mut copy_text = String::new();
let mut copy_byte_boundaries = Vec::new();
let mut copy_column = None;
let mut display_width = 0usize;
for span in row {
if span.copyable {
copy_column.get_or_insert(display_width);
let copy_byte_base = copy_text.len();
copy_text.push_str(&span.copy_text);
append_copy_byte_boundaries(
&mut copy_byte_boundaries,
©_byte_boundaries_for_span(&span.display_text, &span.copy_text),
copy_byte_base,
);
}
display_width =
display_width.saturating_add(UnicodeWidthStr::width(span.display_text.as_str()));
spans.push(Span::styled(span.display_text, span.style));
}
let copyable = copy_column.is_some() || force_copyable;
let left = user_prompt_horizontal_padding(width);
lines.push(TranscriptVisualLine {
status_indicators: Vec::new(),
line: padded_body_line(spans, width, body_style, (left, left)),
copy_text,
copy_byte_range: None,
copyable,
copy_continuation: copyable && copy_continuation,
visual_copy_column_offset: copy_column
.map(|column| display_width_to_u16(left.saturating_add(column)))
.unwrap_or_default(),
visual_copy_byte_boundaries: copy_byte_boundaries,
hit_regions: Vec::new(),
});
}
fn wrap_user_prompt_spans_to_width(
spans: Vec<UserPromptVisualSpan>,
max_width: usize,
) -> Vec<Vec<UserPromptVisualSpan>> {
let max_width = max_width.max(1);
let mut rows: Vec<Vec<UserPromptVisualSpan>> = Vec::new();
let mut row = Vec::new();
let mut row_width = 0usize;
for span in spans {
let mut segment = String::new();
let mut copy_segment = String::new();
let mut segment_has_positive_width = false;
let mut trailing_zero_width_after_full_row = false;
let mut index = 0usize;
while let Some(unit) = next_display_width_unit(&span.display_text, index) {
index = unit.end;
let unit_width = unit.width;
if unit_width == 0
&& trailing_zero_width_after_full_row
&& segment.is_empty()
&& row.is_empty()
&& row_width == 0
&& let Some(last_row) = rows.last_mut()
{
last_row.push(UserPromptVisualSpan::with_copy_text(
unit.text,
if span.copyable { unit.text } else { "" },
span.style,
span.copyable,
));
continue;
}
trailing_zero_width_after_full_row = false;
if unit_width > max_width {
let carries_leading_zero_width = !segment.is_empty() && !segment_has_positive_width;
if segment_has_positive_width && !segment.is_empty() {
row.push(UserPromptVisualSpan::with_copy_text(
std::mem::take(&mut segment),
std::mem::take(&mut copy_segment),
span.style,
span.copyable,
));
}
if !row.is_empty() {
rows.push(std::mem::take(&mut row));
}
row_width = 0;
let mut replacement_display = if carries_leading_zero_width {
std::mem::take(&mut segment)
} else {
String::new()
};
replacement_display.push('�');
let mut replacement_copy = if carries_leading_zero_width {
std::mem::take(&mut copy_segment)
} else {
copy_segment.clear();
String::new()
};
if span.copyable {
replacement_copy.push_str(unit.text);
} else {
replacement_copy.clear();
}
row.push(UserPromptVisualSpan::with_copy_text(
replacement_display,
replacement_copy,
span.style,
span.copyable,
));
rows.push(std::mem::take(&mut row));
trailing_zero_width_after_full_row = true;
segment_has_positive_width = false;
continue;
}
if row_width.saturating_add(unit_width) > max_width
&& (!row.is_empty() || !segment.is_empty())
{
if segment_has_positive_width && !segment.is_empty() {
row.push(UserPromptVisualSpan::with_copy_text(
std::mem::take(&mut segment),
std::mem::take(&mut copy_segment),
span.style,
span.copyable,
));
}
rows.push(std::mem::take(&mut row));
row_width = 0;
}
segment.push_str(unit.text);
if span.copyable {
copy_segment.push_str(unit.text);
}
if unit_width > 0 {
segment_has_positive_width = true;
}
row_width = row_width.saturating_add(unit_width);
if row_width >= max_width {
row.push(UserPromptVisualSpan::with_copy_text(
std::mem::take(&mut segment),
std::mem::take(&mut copy_segment),
span.style,
span.copyable,
));
rows.push(std::mem::take(&mut row));
row_width = 0;
segment_has_positive_width = false;
trailing_zero_width_after_full_row = true;
}
}
if !segment.is_empty() {
row.push(UserPromptVisualSpan::with_copy_text(
segment,
copy_segment,
span.style,
span.copyable,
));
}
}
if rows.is_empty() || !row.is_empty() {
rows.push(row);
}
rows
}
fn append_subagent_card_body_lines(
lines: &mut Vec<TranscriptVisualLine>,
state: &MissionControlState,
card: &TranscriptCard,
active: bool,
width: u16,
body_style: Style,
) {
let Some(subagent_card) = &card.subagent_card else {
return;
};
let Some(batch_id) = subagent_card.batch_id.as_ref() else {
return;
};
let theme = state.theme;
let selected_id = subagent_card.selected_task_id.as_deref();
let ordered = card.children.iter().collect::<Vec<_>>();
append_subagent_card_roster(
lines,
&ordered,
batch_id,
selected_id,
active,
width,
body_style,
theme,
);
push_subagent_card_row(lines, Vec::new(), width, body_style);
let selected = selected_id
.and_then(|selected| card.children.iter().find(|child| child.task_id == selected));
if let Some(selected) = selected {
push_subagent_card_row(
lines,
vec![
SubagentCardSegment::plain(
format!(
"{} {}{}{}",
subagent_card_status_glyph(selected.status),
selected.task_id,
selected
.identity
.as_ref()
.map(|identity| format!(" · {identity}"))
.unwrap_or_default(),
selected
.model_line
.as_ref()
.map(|model| format!(" · {model}"))
.unwrap_or_default(),
),
subagent_card_status_style(selected.status, theme),
)
.with_status_indicator(
selected.status,
Some(0),
selected.activity_id.clone(),
),
],
width,
body_style,
);
let intent = if selected.intent.is_empty() {
selected.label.as_str()
} else {
selected.intent.as_str()
};
push_subagent_card_row(
lines,
vec![SubagentCardSegment::plain(
intent,
theme
.transcript_tool_body()
.bg(card_body_background(card.role, theme)),
)],
width,
body_style,
);
push_subagent_card_row(lines, Vec::new(), width, body_style);
} else {
push_subagent_card_row(lines, Vec::new(), width, body_style);
push_subagent_card_row(lines, Vec::new(), width, body_style);
}
push_subagent_box_edge(lines, width, body_style, theme, true);
if let Some(selected) = selected
&& let Some(usage) = selected.usage
{
let row = format_subagent_usage(usage, selected, width, body_style, theme);
if !row.is_empty() {
push_subagent_box_row(lines, row, width, body_style, body_style, theme);
}
}
let mut activity_rows = selected
.into_iter()
.flat_map(|selected| &selected.live_tail)
.map(|tail| {
SubagentCardSegment::plain(tail, theme.muted(active).bg(theme.surface_background()))
})
.collect::<Vec<_>>();
if activity_rows.is_empty()
&& let Some(body) = selected
.map(|selected| selected.body.as_str())
.filter(|body| !body.is_empty())
{
activity_rows.push(SubagentCardSegment::plain(
format!("✗ {body}"),
theme.transcript_error_body().bg(theme.surface_background()),
));
}
let activity_row_count = activity_rows.len().min(state.subagent_card_rows());
for row in activity_rows.into_iter().take(state.subagent_card_rows()) {
push_subagent_feed_row(lines, Some(row), width, body_style, theme);
}
for _ in activity_row_count..state.subagent_card_rows() {
push_subagent_feed_row(lines, None, width, body_style, theme);
}
push_subagent_box_edge(lines, width, body_style, theme, false);
push_subagent_card_row(lines, Vec::new(), width, body_style);
let open = "[View Subagent ↗]";
let open_target = selected
.and_then(|selected| selected.activity_id.clone())
.map(|task_id| {
SubagentCardSegment::control(
open,
theme.accent(active).add_modifier(Modifier::BOLD),
TranscriptHitTarget::OpenTranscript {
batch_id: batch_id.clone(),
task_id,
},
)
});
if let Some(open_target) = open_target {
let spacer = " ".repeat(
subagent_card_content_width(width).saturating_sub(UnicodeWidthStr::width(open)),
);
push_subagent_card_row(
lines,
vec![SubagentCardSegment::plain(spacer, body_style), open_target],
width,
body_style,
);
}
}
fn push_subagent_box_edge(
lines: &mut Vec<TranscriptVisualLine>,
width: u16,
body_style: Style,
theme: MissionControlTheme,
top: bool,
) {
let available = subagent_card_content_width(width);
if available < 4 {
return;
}
let edge = "─".repeat(available - 2);
let text = if top {
format!("╭{edge}╮")
} else {
format!("╰{edge}╯")
};
push_subagent_card_row(
lines,
vec![SubagentCardSegment::plain(
text,
body_style.fg(theme.border_alternate()),
)],
width,
body_style,
);
}
fn push_subagent_box_row(
lines: &mut Vec<TranscriptVisualLine>,
row: Vec<SubagentCardSegment>,
width: u16,
body_style: Style,
row_style: Style,
theme: MissionControlTheme,
) {
let available = subagent_card_content_width(width);
if available < 4 {
push_subagent_card_row(lines, Vec::new(), width, body_style);
return;
}
let border_style = body_style.fg(theme.border_alternate());
let mut segments = vec![
SubagentCardSegment::plain("│", border_style),
SubagentCardSegment::plain(" ", row_style),
];
let mut remaining = available - 4;
for segment in row {
let text = truncate_to_display_width(&segment.text, remaining);
remaining = remaining.saturating_sub(UnicodeWidthStr::width(text.as_str()));
segments.push(SubagentCardSegment::plain(text, segment.style));
}
segments.push(SubagentCardSegment::plain(
" ".repeat(remaining + 1),
row_style,
));
segments.push(SubagentCardSegment::plain("│", border_style));
push_subagent_card_row(lines, segments, width, body_style);
if let Some(line) = lines.last_mut() {
let interior_start = horizontal_padding(width).0 + SUBAGENT_CARD_CONTENT_INDENT + 1;
let interior_end = interior_start + available - 2;
let mut column = 0;
for span in &mut line.line.spans {
let end = column + span.width();
if column >= interior_start && end <= interior_end {
span.style.bg = row_style.bg;
}
column = end;
}
}
}
fn subagent_card_status_style(status: TranscriptCardStatus, theme: MissionControlTheme) -> Style {
let style = match status {
TranscriptCardStatus::Running => Style::default().fg(theme.text_command()),
TranscriptCardStatus::None => Style::default().fg(theme.text_status()),
_ => child_status_style(status, theme),
};
style.add_modifier(Modifier::BOLD)
}
fn format_subagent_usage(
usage: crate::output::NormalizedUsageAggregate,
child: &TranscriptCardChild,
width: u16,
style: Style,
theme: MissionControlTheme,
) -> Vec<SubagentCardSegment> {
use crate::tui::{activity::cache_read_percent, usage_format::compact_tokens};
let whole_run = cache_read_percent(usage.whole_run).or(child.last_known_cache_percent);
let latest = usage
.latest
.and_then(cache_read_percent)
.or(child.last_known_latest_cache_percent);
let cache = |percent: Option<u64>| {
percent.map_or_else(|| "—".to_string(), |percent| format!("{percent}%"))
};
let cache_style = style.fg(theme.activity_status_color(crate::output::ActivityStatus::Success));
let fields = [
(
"Tks ",
format!("{}(in)", compact_tokens(usage.whole_run.effective_input)),
style.fg(theme.transcript_user_color()),
),
(
"| ",
format!("{}(out)", compact_tokens(usage.whole_run.output)),
style.fg(theme.text_accent()),
),
(
" • Cache ",
format!("{}(total)", cache(whole_run)),
cache_style,
),
("| ", format!("{}(latest)", cache(latest)), cache_style),
];
let mut remaining = subagent_card_content_width(width).saturating_sub(4);
let mut segments = Vec::new();
for (label, value, field_style) in fields {
let used = UnicodeWidthStr::width(label) + UnicodeWidthStr::width(value.as_str());
if used > remaining {
break;
}
segments.push(SubagentCardSegment::plain(
label,
style.fg(theme.text_muted()),
));
segments.push(SubagentCardSegment::plain(value, field_style));
remaining -= used;
}
segments
}
fn subagent_card_status_glyph(status: TranscriptCardStatus) -> &'static str {
match status {
TranscriptCardStatus::Success => "✓",
TranscriptCardStatus::Failed => "✗",
TranscriptCardStatus::Running => "●",
TranscriptCardStatus::Canceled => "⊘",
TranscriptCardStatus::None => "○",
}
}
fn subagent_card_chip_text(child: &TranscriptCardChild) -> String {
let name = child
.agent
.as_deref()
.filter(|name| !name.is_empty())
.or_else(|| child.identity.as_deref().filter(|name| !name.is_empty()));
name.map_or_else(
|| {
format!(
"[{} {}]",
child.task_id,
subagent_card_status_glyph(child.status)
)
},
|name| {
format!(
"[{} {} {}]",
child.task_id,
name,
subagent_card_status_glyph(child.status)
)
},
)
}
#[allow(clippy::too_many_arguments)]
fn append_subagent_card_roster(
lines: &mut Vec<TranscriptVisualLine>,
ordered: &[&super::projection::TranscriptCardChild],
batch_id: &crate::output::ActivityId,
selected_id: Option<&str>,
active: bool,
width: u16,
body_style: Style,
theme: MissionControlTheme,
) {
let available = subagent_card_content_width(width);
let mut rows: Vec<Vec<&super::projection::TranscriptCardChild>> = vec![Vec::new()];
let mut row_width = 0usize;
let mut omitted = 0usize;
for child in ordered {
let chip = subagent_card_chip_text(child);
let chip_width = UnicodeWidthStr::width(chip.as_str());
let separator = usize::from(!rows.last().is_none_or(Vec::is_empty));
if row_width
.saturating_add(separator)
.saturating_add(chip_width)
<= available
|| rows.last().is_some_and(Vec::is_empty)
{
rows.last_mut().expect("roster row").push(child);
row_width = row_width
.saturating_add(separator)
.saturating_add(chip_width);
} else if rows.len() < 2 {
rows.push(vec![child]);
row_width = chip_width;
} else {
omitted = omitted.saturating_add(1);
}
}
if omitted > 0 {
let marker_width = UnicodeWidthStr::width(format!("+{omitted} more").as_str());
while rows.last().is_some_and(|row| {
!row.is_empty() && row_width.saturating_add(1 + marker_width) > available
}) {
if let Some(removed) = rows.last_mut().and_then(Vec::pop) {
let removed_width =
UnicodeWidthStr::width(subagent_card_chip_text(removed).as_str());
row_width = row_width.saturating_sub(removed_width);
if !rows.last().is_none_or(Vec::is_empty) {
row_width = row_width.saturating_sub(1);
}
omitted = omitted.saturating_add(1);
}
}
}
for (row_index, row) in rows.iter().enumerate().take(2) {
let mut segments = Vec::new();
for (index, child) in row.iter().enumerate() {
if index > 0 {
segments.push(SubagentCardSegment::plain(" ", body_style));
}
let selected = selected_id == Some(child.task_id.as_str());
let style = subagent_card_status_style(child.status, theme)
.bg(card_body_background(
TranscriptCardRole::SubagentBatch,
theme,
))
.add_modifier(if selected {
Modifier::BOLD | Modifier::REVERSED
} else {
Modifier::empty()
});
if let Some(task_id) = child.activity_id.clone() {
let chip_text = subagent_card_chip_text(child);
let indicator_byte = chip_text.rfind(subagent_card_status_glyph(child.status));
segments.push(
SubagentCardSegment::control(
chip_text,
style,
TranscriptHitTarget::SelectTask {
batch_id: batch_id.clone(),
task_id,
},
)
.with_status_indicator(
child.status,
indicator_byte,
child.activity_id.clone(),
),
);
}
}
if row_index + 1 == rows.len() && omitted > 0 {
if !segments.is_empty() {
segments.push(SubagentCardSegment::plain(" ", body_style));
}
segments.push(SubagentCardSegment::plain(
format!("+{omitted} more"),
theme.dim(active).bg(card_body_background(
TranscriptCardRole::SubagentBatch,
theme,
)),
));
}
push_subagent_card_row(lines, segments, width, body_style);
}
}
struct SubagentCardSegment {
text: String,
style: Style,
target: Option<TranscriptHitTarget>,
status_indicator: Option<(usize, Option<crate::output::ActivityId>)>,
}
impl SubagentCardSegment {
fn with_status_indicator(
mut self,
status: TranscriptCardStatus,
byte: Option<usize>,
id: Option<crate::output::ActivityId>,
) -> Self {
if matches!(
status,
TranscriptCardStatus::Running | TranscriptCardStatus::Success
) {
self.status_indicator = byte.map(|byte| (byte, id));
}
self
}
fn plain(text: impl Into<String>, style: Style) -> Self {
Self {
text: text.into(),
style,
target: None,
status_indicator: None,
}
}
fn control(text: impl Into<String>, style: Style, target: TranscriptHitTarget) -> Self {
Self {
text: text.into(),
style,
target: Some(target),
status_indicator: None,
}
}
}
const SUBAGENT_CARD_CONTENT_INDENT: usize = 2;
fn subagent_card_content_width(width: u16) -> usize {
body_wrap_width(width).saturating_sub(SUBAGENT_CARD_CONTENT_INDENT)
}
fn push_subagent_feed_row(
lines: &mut Vec<TranscriptVisualLine>,
segment: Option<SubagentCardSegment>,
width: u16,
body_style: Style,
theme: MissionControlTheme,
) {
push_subagent_box_row(
lines,
segment.into_iter().collect(),
width,
body_style,
body_style.bg(theme.surface_background()),
theme,
);
}
fn push_subagent_card_row(
lines: &mut Vec<TranscriptVisualLine>,
segments: Vec<SubagentCardSegment>,
width: u16,
body_style: Style,
) {
let available = body_wrap_width(width);
let left = horizontal_padding(width).0;
let mut spans = Vec::new();
let mut hit_regions = Vec::new();
let mut status_indicators = Vec::new();
let indent = " ".repeat(SUBAGENT_CARD_CONTENT_INDENT.min(available));
let mut used = UnicodeWidthStr::width(indent.as_str());
if !indent.is_empty() {
spans.push(Span::styled(indent, body_style));
}
for segment in segments {
if used >= available {
break;
}
let visible = truncate_to_display_width(&segment.text, available.saturating_sub(used));
let visible_width = UnicodeWidthStr::width(visible.as_str());
if visible_width == 0 {
continue;
}
if let Some((byte, id)) = segment.status_indicator
&& visible
.get(byte..)
.is_some_and(|tail| tail.starts_with(['●', '✓']))
{
status_indicators.push((
display_width_to_u16(left + used + UnicodeWidthStr::width(&visible[..byte])),
id,
));
}
if let Some(target) = segment.target {
hit_regions.push(TranscriptHitRegion {
columns: display_width_to_u16(left.saturating_add(used))
..display_width_to_u16(left.saturating_add(used).saturating_add(visible_width)),
target,
});
}
used = used.saturating_add(visible_width);
spans.push(Span::styled(visible, segment.style));
}
let copy_text = spans_text(&spans);
lines.push(TranscriptVisualLine {
status_indicators,
line: wide_body_line(spans, width, body_style),
copy_text,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: display_width_to_u16(left),
visual_copy_byte_boundaries: Vec::new(),
hit_regions,
});
}
fn child_status_style(status: TranscriptCardStatus, theme: MissionControlTheme) -> Style {
match status {
TranscriptCardStatus::Success => theme.accent(false),
TranscriptCardStatus::Failed => theme.transcript_error_label(),
TranscriptCardStatus::Running => theme.transcript_tool_body(),
TranscriptCardStatus::Canceled => theme.muted(false),
TranscriptCardStatus::None => theme.muted(false),
}
}
fn push_text_line(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
text: String,
style: Style,
is_body: bool,
width: u16,
body_style: Style,
) {
push_wide_spans(
lines,
card,
vec![Span::styled(text, style)],
is_body,
width,
body_style,
);
}
fn push_wide_spans(
lines: &mut Vec<TranscriptVisualLine>,
_card: &TranscriptCard,
spans: Vec<Span<'static>>,
_is_body: bool,
width: u16,
edge_style: Style,
) {
for (index, row_spans) in wrap_spans_to_width(spans, body_wrap_width(width))
.into_iter()
.enumerate()
{
let copy_text = spans_text(&row_spans);
lines.push(TranscriptVisualLine {
status_indicators: Vec::new(),
line: wide_body_line(row_spans, width, edge_style),
copy_text,
copy_byte_range: None,
hit_regions: Vec::new(),
copyable: true,
copy_continuation: index > 0,
visual_copy_column_offset: body_line_copy_offset(width, 0),
visual_copy_byte_boundaries: Vec::new(),
});
}
}
#[derive(Debug, Clone)]
struct AssistantVisualSpan {
text: String,
style: Style,
role: Option<DisplayRole>,
}
impl AssistantVisualSpan {
fn styled<T: Into<String>>(text: T, style: Style, role: Option<DisplayRole>) -> Self {
Self {
text: text.into(),
style,
role,
}
}
fn into_ratatui_span(self) -> Span<'static> {
Span::styled(self.text, self.style)
}
}
fn push_wide_assistant_spans(
lines: &mut Vec<TranscriptVisualLine>,
_card: &TranscriptCard,
spans: Vec<AssistantVisualSpan>,
_is_body: bool,
width: u16,
edge_style: Style,
) {
for (index, row_spans) in wrap_assistant_spans_to_width(spans, body_wrap_width(width))
.into_iter()
.enumerate()
{
let copy_text = assistant_wide_copy_text(&row_spans);
let gutter = assistant_code_gutter_width(&row_spans);
lines.push(TranscriptVisualLine {
status_indicators: Vec::new(),
line: wide_body_line(
row_spans
.into_iter()
.map(AssistantVisualSpan::into_ratatui_span)
.collect(),
width,
edge_style,
),
copy_text,
copy_byte_range: None,
copyable: true,
hit_regions: Vec::new(),
copy_continuation: index > 0,
visual_copy_column_offset: body_line_copy_offset(width, gutter),
visual_copy_byte_boundaries: Vec::new(),
});
}
}
fn assistant_wide_copy_text(spans: &[AssistantVisualSpan]) -> String {
spans
.iter()
.filter(|span| span.role != Some(DisplayRole::CodeBlockGutter))
.map(|span| span.text.as_str())
.collect()
}
fn assistant_code_gutter_width(spans: &[AssistantVisualSpan]) -> u16 {
display_width_to_u16(
spans
.iter()
.filter(|span| span.role == Some(DisplayRole::CodeBlockGutter))
.map(|span| UnicodeWidthStr::width(span.text.as_str()))
.sum(),
)
}
fn display_width_to_u16(width: usize) -> u16 {
width.min(usize::from(u16::MAX)) as u16
}
fn wide_body_line(
content_spans: Vec<Span<'static>>,
width: u16,
body_style: Style,
) -> Line<'static> {
padded_body_line(content_spans, width, body_style, horizontal_padding(width))
}
fn padded_body_line(
mut content_spans: Vec<Span<'static>>,
width: u16,
body_style: Style,
(left, right): (usize, usize),
) -> Line<'static> {
for span in &mut content_spans {
span.style = span
.style
.bg(body_style.bg.unwrap_or(ratatui::style::Color::Reset));
}
let content_width = spans_display_width(&content_spans);
let available = usize::from(width)
.saturating_sub(left)
.saturating_sub(right);
let padding = available.saturating_sub(content_width);
let mut result = Vec::new();
if left > 0 {
result.push(Span::styled(" ".repeat(left), body_style));
}
result.extend(content_spans);
if padding.saturating_add(right) > 0 {
result.push(Span::styled(
" ".repeat(padding.saturating_add(right)),
body_style,
));
}
Line::from(result)
}
pub(super) fn wrap_spans_to_width(
spans: Vec<Span<'static>>,
max_width: usize,
) -> Vec<Vec<Span<'static>>> {
let max_width = max_width.max(1);
let mut rows = Vec::new();
let mut row = Vec::new();
let mut row_width = 0usize;
for span in spans {
let style = span.style;
let mut segment = String::new();
let mut index = 0usize;
let content = span.content.as_ref();
while let Some(unit) = next_display_width_unit(content, index) {
index = unit.end;
let unit_width = unit.width;
if row_width.saturating_add(unit_width) > max_width
&& (!row.is_empty() || !segment.is_empty())
{
if !segment.is_empty() {
row.push(Span::styled(std::mem::take(&mut segment), style));
}
rows.push(std::mem::take(&mut row));
row_width = 0;
}
segment.push_str(unit.text);
row_width = row_width.saturating_add(unit_width);
if row_width >= max_width {
row.push(Span::styled(std::mem::take(&mut segment), style));
rows.push(std::mem::take(&mut row));
row_width = 0;
}
}
if !segment.is_empty() {
row.push(Span::styled(segment, style));
}
}
if rows.is_empty() || !row.is_empty() {
rows.push(row);
}
rows
}
fn wrap_assistant_spans_to_width(
spans: Vec<AssistantVisualSpan>,
max_width: usize,
) -> Vec<Vec<AssistantVisualSpan>> {
let max_width = max_width.max(1);
let mut rows = Vec::new();
let mut row = Vec::new();
let mut row_width = 0usize;
for span in spans {
let style = span.style;
let role = span.role;
let mut segment = String::new();
let mut index = 0usize;
while let Some(unit) = next_display_width_unit(&span.text, index) {
index = unit.end;
let unit_width = unit.width;
if row_width.saturating_add(unit_width) > max_width
&& (!row.is_empty() || !segment.is_empty())
{
if !segment.is_empty() {
row.push(AssistantVisualSpan::styled(
std::mem::take(&mut segment),
style,
role,
));
}
rows.push(std::mem::take(&mut row));
row_width = 0;
}
segment.push_str(unit.text);
row_width = row_width.saturating_add(unit_width);
if row_width >= max_width {
row.push(AssistantVisualSpan::styled(
std::mem::take(&mut segment),
style,
role,
));
rows.push(std::mem::take(&mut row));
row_width = 0;
}
}
if !segment.is_empty() {
row.push(AssistantVisualSpan::styled(segment, style, role));
}
}
if rows.is_empty() || !row.is_empty() {
rows.push(row);
}
rows
}
#[derive(Debug, Clone, Copy)]
struct DisplayWidthUnit<'a> {
text: &'a str,
end: usize,
width: usize,
}
fn next_display_width_unit(text: &str, start: usize) -> Option<DisplayWidthUnit<'_>> {
let (offset, unit) = text[start..]
.grapheme_indices(true)
.find(|(_, grapheme)| !grapheme.chars().any(char::is_control))?;
let end = start + offset + unit.len();
Some(DisplayWidthUnit {
text: unit,
end,
width: UnicodeWidthStr::width(unit),
})
}
pub(super) fn spans_text(spans: &[Span<'static>]) -> String {
spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
pub(super) fn spans_display_width(spans: &[Span<'static>]) -> usize {
spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
fn transcript_gap_visual_line(width: u16, theme: MissionControlTheme) -> TranscriptVisualLine {
TranscriptVisualLine {
status_indicators: Vec::new(),
line: Line::from(Span::styled(
" ".repeat(usize::from(width)),
theme.pane().bg(theme.surface_background()),
)),
copy_text: String::new(),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
visual_copy_byte_boundaries: Vec::new(),
hit_regions: Vec::new(),
}
}
fn transcript_bottom_padding_visual_line(
width: u16,
theme: MissionControlTheme,
) -> TranscriptVisualLine {
let mut line = transcript_gap_visual_line(width, theme);
line.copyable = false;
line
}
fn truncate_to_display_width(text: &str, max_width: usize) -> String {
let mut out = String::new();
let mut width = 0usize;
let mut index = 0usize;
while let Some(unit) = next_display_width_unit(text, index) {
if width.saturating_add(unit.width) > max_width {
break;
}
out.push_str(unit.text);
width = width.saturating_add(unit.width);
index = unit.end;
}
out
}
fn pad_full_width_spans(mut spans: Vec<Span<'static>>, width: u16) -> Vec<Span<'static>> {
let used = spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum::<usize>();
let style = spans.last().map(|span| span.style).unwrap_or_default();
let padding = usize::from(width).saturating_sub(used);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), style));
}
spans
}
fn card_header_background(
role: TranscriptCardRole,
theme: MissionControlTheme,
) -> ratatui::style::Color {
if role == TranscriptCardRole::Error {
theme.surface_error()
} else {
theme.surface_panel_alt()
}
}
fn status_header_style(status: TranscriptCardStatus, theme: MissionControlTheme) -> Style {
match status {
TranscriptCardStatus::Running => Style::new()
.fg(theme.activity_status_color(ActivityStatus::Running))
.add_modifier(Modifier::BOLD),
TranscriptCardStatus::Success => {
Style::new().fg(theme.activity_status_color(ActivityStatus::Success))
}
TranscriptCardStatus::Failed => Style::new()
.fg(theme.activity_status_color(ActivityStatus::Failed))
.add_modifier(Modifier::BOLD),
TranscriptCardStatus::Canceled => Style::new()
.fg(theme.activity_status_color(ActivityStatus::Canceled))
.add_modifier(Modifier::DIM),
TranscriptCardStatus::None => theme.muted(false),
}
}
fn role_label_style(role: TranscriptCardRole, active: bool, theme: MissionControlTheme) -> Style {
match role {
TranscriptCardRole::Error => theme.transcript_error_label(),
TranscriptCardRole::Session
| TranscriptCardRole::Diagnostic
| TranscriptCardRole::Compaction
| TranscriptCardRole::Empty => theme.muted(active),
TranscriptCardRole::Bash => theme.accent(active).add_modifier(Modifier::BOLD),
TranscriptCardRole::Reasoning => theme.dim(active).add_modifier(Modifier::BOLD),
TranscriptCardRole::Tool
| TranscriptCardRole::HashEdit
| TranscriptCardRole::Write
| TranscriptCardRole::ViewImage
| TranscriptCardRole::Read
| TranscriptCardRole::Grep
| TranscriptCardRole::AstGrep
| TranscriptCardRole::Find
| TranscriptCardRole::ListFiles
| TranscriptCardRole::SubagentBatch
| TranscriptCardRole::UserPrompt
| TranscriptCardRole::Assistant => theme.accent(active).add_modifier(Modifier::BOLD),
}
}
fn role_body_style(role: TranscriptCardRole, _active: bool, theme: MissionControlTheme) -> Style {
match role {
TranscriptCardRole::UserPrompt | TranscriptCardRole::Bash => theme.transcript_user_body(),
TranscriptCardRole::Tool
| TranscriptCardRole::HashEdit
| TranscriptCardRole::Write
| TranscriptCardRole::ViewImage
| TranscriptCardRole::Read
| TranscriptCardRole::Grep
| TranscriptCardRole::AstGrep
| TranscriptCardRole::Find
| TranscriptCardRole::ListFiles
| TranscriptCardRole::SubagentBatch => theme.transcript_tool_body(),
TranscriptCardRole::Error => theme.transcript_error_body(),
TranscriptCardRole::Session
| TranscriptCardRole::Diagnostic
| TranscriptCardRole::Compaction => theme.transcript_session_body(),
TranscriptCardRole::Reasoning => theme.dim(false),
TranscriptCardRole::Assistant | TranscriptCardRole::Empty => theme.pane(),
}
}
fn card_body_background(
role: TranscriptCardRole,
theme: MissionControlTheme,
) -> ratatui::style::Color {
if role == TranscriptCardRole::Error {
theme.surface_error()
} else {
theme.surface_panel()
}
}
fn display_status_mark(card: &TranscriptCard) -> &'static str {
match card.role {
TranscriptCardRole::Assistant
| TranscriptCardRole::UserPrompt
| TranscriptCardRole::Reasoning
| TranscriptCardRole::SubagentBatch => "",
_ => status_mark(card.status),
}
}
pub(super) fn status_mark(status: TranscriptCardStatus) -> &'static str {
match status {
TranscriptCardStatus::Running => activity_status_mark(ActivityStatus::Running),
TranscriptCardStatus::Success => activity_status_mark(ActivityStatus::Success),
TranscriptCardStatus::Failed => activity_status_mark(ActivityStatus::Failed),
TranscriptCardStatus::Canceled => activity_status_mark(ActivityStatus::Canceled),
TranscriptCardStatus::None => "",
}
}