use super::{
ACTIVITY_BUTTON_LABEL, CARD_LAYOUT_MIN_WIDTH, TRANSCRIPT_VIEWPORT_OVERSCAN_ROWS,
projection::{
SquadSummary, TranscriptCard, TranscriptCardRole, TranscriptCardStatus,
cached_project_entry, project_cards,
},
};
use crate::{
output::{ActivityId, ActivityStatus},
rendering::{DisplayLine, DisplayRole, plain_projection},
tui::{
state::{MissionControlState, activity_status_mark},
theme::MATRIX_GREEN,
transcript::{self, TRANSCRIPT_TURN_SEPARATOR},
},
};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use std::cell::Ref;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptActivityButton {
pub(crate) activity_id: ActivityId,
pub(crate) start_column: u16,
pub(crate) end_column: u16,
}
#[derive(Debug, Clone)]
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) visual_copy_column_offset: u16,
pub(crate) activity_button: Option<TranscriptActivityButton>,
}
pub(crate) fn visual_lines(state: &MissionControlState, width: u16) -> Vec<TranscriptVisualLine> {
let mut lines = if width >= CARD_LAYOUT_MIN_WIDTH {
wide_visual_lines(state, width)
} else {
compact_visual_lines(state)
};
annotate_copy_byte_ranges(&mut lines, 0, false);
lines
}
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 active = state.is_transcript_focused();
let needs_refresh = state
.transcript_cache
.borrow()
.visual_lines
.get(&width)
.is_none_or(|cached| cached.generation != generation || cached.active != active);
if needs_refresh {
let lines = visual_lines(state, width);
state.transcript_cache.borrow_mut().visual_lines.insert(
width,
transcript::CachedTranscriptVisualLines {
generation,
active,
lines,
},
);
}
Ref::map(state.transcript_cache.borrow(), |cache| {
&cache
.visual_lines
.get(&width)
.expect("transcript visual lines cached")
.lines
})
}
pub(crate) fn visible_scrollback_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
scrollback_rows: usize,
) -> VisibleTranscriptLines {
visible_scrollback_visual_lines(state, width, visible_rows, scrollback_rows)
.into_visible_lines()
}
pub(crate) fn visible_tail_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
) -> VisibleTranscriptLines {
visible_scrollback_lines(state, width, visible_rows, 0)
}
#[derive(Debug, Clone)]
pub(crate) struct VisibleTranscriptLines {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) scroll_offset: usize,
pub(crate) has_overflow_above: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct VisibleTranscriptVisualLines {
pub(crate) lines: Vec<TranscriptVisualLine>,
pub(crate) scroll_offset: usize,
pub(crate) has_overflow_above: bool,
#[cfg(test)]
pub(crate) visual_start_row: usize,
}
impl VisibleTranscriptVisualLines {
fn into_visible_lines(self) -> VisibleTranscriptLines {
VisibleTranscriptLines {
lines: self.lines.into_iter().map(|line| line.line).collect(),
scroll_offset: self.scroll_offset,
has_overflow_above: self.has_overflow_above,
}
}
}
fn annotate_copy_byte_ranges(
lines: &mut [TranscriptVisualLine],
mut byte_base: usize,
mut copy_line_seen: bool,
) -> (usize, bool) {
for line in lines {
line.copy_byte_range = None;
if !line.copyable {
continue;
}
if !line.copy_continuation && copy_line_seen {
byte_base = byte_base.saturating_add(1);
}
copy_line_seen = true;
let line_start = byte_base;
let line_end = line_start.saturating_add(line.copy_text.len());
line.copy_byte_range = Some((line_start, line_end));
byte_base = line_end;
}
(byte_base, copy_line_seen)
}
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(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 {
lines: visible_lines,
scroll_offset,
has_overflow_above: visible_start > 0,
#[cfg(test)]
visual_start_row: visible_start,
};
}
let mut user_turn_seen = false;
let mut byte_base = 0usize;
let mut copy_line_seen = false;
for (entry_index, entry) in state.transcript.iter().enumerate() {
let card = cached_project_entry(state, entry_index, entry);
let prepend_separator = card.role == TranscriptCardRole::UserPrompt && user_turn_seen;
let mut block = card_visual_lines(
&card,
state.is_transcript_focused(),
width,
entry_index > 0,
prepend_separator,
);
if card.role == TranscriptCardRole::UserPrompt {
user_turn_seen = true;
}
let (next_byte_base, next_copy_line_seen) =
annotate_copy_byte_ranges(&mut block, byte_base, copy_line_seen);
byte_base = next_byte_base;
copy_line_seen = next_copy_line_seen;
for line in block {
if current_row >= stop_row {
return VisibleTranscriptVisualLines {
lines: visible_lines,
scroll_offset,
has_overflow_above: visible_start > 0,
#[cfg(test)]
visual_start_row: visible_start,
};
}
append_visible_visual_line(
line,
width,
visible_start,
visible_end,
&mut current_row,
&mut visible_lines,
&mut scroll_offset,
);
}
}
VisibleTranscriptVisualLines {
lines: visible_lines,
scroll_offset,
has_overflow_above: visible_start > 0,
#[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(TRANSCRIPT_VIEWPORT_OVERSCAN_ROWS);
if state.transcript.is_empty() {
return visible_visual_lines_windowed(state, width, 0, visible_rows as u16);
}
let mut blocks = Vec::new();
let mut collected_rows = 0usize;
let mut reached_start = false;
for entry_index in (0..state.transcript.len()).rev() {
let entry = &state.transcript[entry_index];
let card = cached_project_entry(state, entry_index, entry);
let prepend_separator = card.role == TranscriptCardRole::UserPrompt
&& has_user_prompt_before(state, entry_index);
let block = card_visual_lines(
&card,
state.is_transcript_focused(),
width,
entry_index > 0,
prepend_separator,
);
collected_rows = collected_rows.saturating_add(visual_rows_for_lines(&block, width));
blocks.push((entry_index, block));
if entry_index == 0 {
reached_start = true;
break;
}
if collected_rows >= target_rows {
break;
}
}
let lines = blocks
.into_iter()
.rev()
.flat_map(|(_, block)| block)
.collect::<Vec<_>>();
let visible_start = collected_rows
.saturating_sub(visible_rows)
.saturating_sub(scrollback_rows);
let mut visible =
visible_visual_lines_from_visual_lines(&lines, width, visible_start, visible_rows as u16);
visible.has_overflow_above = !reached_start || visible_start > 0;
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);
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_visual_lines(
lines: &[TranscriptVisualLine],
width: u16,
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 visual_line in lines {
let rows = visual_line_rows(visual_line, width);
let next_row = current_row.saturating_add(rows);
if next_row <= visible_start {
current_row = next_row;
continue;
}
if current_row >= visible_end {
break;
}
if visible_lines.is_empty() {
scroll_offset = visible_start.saturating_sub(current_row);
}
visible_lines.push(visual_line.clone());
current_row = next_row;
}
VisibleTranscriptVisualLines {
lines: visible_lines,
scroll_offset,
has_overflow_above: visible_start > 0,
#[cfg(test)]
visual_start_row: visible_start,
}
}
fn compact_visual_lines(state: &MissionControlState) -> Vec<TranscriptVisualLine> {
transcript_visual_lines_for_width(state, CARD_LAYOUT_MIN_WIDTH.saturating_sub(1))
}
fn wide_visual_lines(state: &MissionControlState, width: u16) -> Vec<TranscriptVisualLine> {
transcript_visual_lines_for_width(state, width)
}
fn transcript_visual_lines_for_width(
state: &MissionControlState,
width: u16,
) -> Vec<TranscriptVisualLine> {
let cards = project_cards(state);
let mut lines = Vec::new();
let mut user_turn_seen = false;
for (index, card) in cards.iter().enumerate() {
let prepend_separator = card.role == TranscriptCardRole::UserPrompt && user_turn_seen;
append_card_visual_lines(
&mut lines,
card,
state.is_transcript_focused(),
width,
index > 0,
prepend_separator,
);
if card.role == TranscriptCardRole::UserPrompt {
user_turn_seen = true;
}
}
lines
}
fn card_visual_lines(
card: &TranscriptCard,
active: bool,
width: u16,
prepend_blank: bool,
prepend_separator: bool,
) -> Vec<TranscriptVisualLine> {
let mut lines = Vec::new();
append_card_visual_lines(
&mut lines,
card,
active,
width,
prepend_blank,
prepend_separator,
);
lines
}
fn append_card_visual_lines(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
active: bool,
width: u16,
prepend_blank: bool,
prepend_separator: bool,
) {
if prepend_blank {
lines.push(blank_visual_line(card));
}
if prepend_separator {
if width >= CARD_LAYOUT_MIN_WIDTH {
lines.push(wide_separator_visual_line(card, width));
} else {
lines.push(separator_visual_line(card));
}
}
if width >= CARD_LAYOUT_MIN_WIDTH {
append_wide_card_lines(lines, card, active, width);
} else {
append_compact_card_lines(lines, card, active);
}
}
fn visual_rows_for_lines(lines: &[TranscriptVisualLine], width: u16) -> usize {
lines.iter().map(|line| visual_line_rows(line, width)).sum()
}
fn has_user_prompt_before(state: &MissionControlState, entry_index: usize) -> bool {
state.transcript.iter().take(entry_index).any(|entry| {
transcript::transcript_entry_parts(entry).is_some_and(|(_, kind, _)| kind == "you")
})
}
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 {
transcript::ratatui_wrapped_line_rows(vec![line.line.clone()], width)
}
fn card_header_title(card: &TranscriptCard) -> String {
if card.role == TranscriptCardRole::SubagentBatch {
return squad_header_text(card);
}
if matches!(card.role, TranscriptCardRole::Tool) {
return format!("🔧Tool: {}", card.title);
}
let status = display_status_mark(card);
if status.is_empty() {
card.title.clone()
} else {
format!("{status} {}", card.title)
}
}
fn squad_header_text(card: &TranscriptCard) -> String {
let Some(squad) = &card.squad else {
return "🛰 Squad".to_string();
};
let progress = squad_progress_bar(squad, 8);
let tally = squad_tally(squad);
if tally.is_empty() {
format!("🛰 Squad ── {progress} {}/{}", squad.done, squad.total)
} else {
format!(
"🛰 Squad ── {progress} {}/{} ── {}",
squad.done, squad.total, tally
)
}
}
fn squad_progress_bar(squad: &SquadSummary, cells: usize) -> String {
let total = squad.total.max(1);
let completed = squad.done.saturating_add(squad.failed);
let mut filled = completed.saturating_mul(cells) / total;
let running_cell = if squad.running > 0 && filled < cells {
1
} else {
0
};
filled = filled.saturating_sub(running_cell);
let empty = cells.saturating_sub(filled).saturating_sub(running_cell);
format!(
"{}{}{}",
"█".repeat(filled),
"▒".repeat(running_cell),
"░".repeat(empty)
)
}
fn squad_tally(squad: &SquadSummary) -> String {
let mut parts = Vec::new();
if squad.running > 0 {
parts.push(format!("⟳{}", squad.running));
}
if squad.failed > 0 {
parts.push(format!("✗{}", squad.failed));
}
parts.join(" ")
}
fn append_compact_card_lines(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
active: bool,
) {
let label_style = role_label_style(card.role, active);
let body_style = role_body_style(card.role, active);
let title = card_header_title(card);
lines.push(TranscriptVisualLine {
line: Line::from(Span::styled(title.clone(), label_style)),
copy_text: title,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
});
append_card_body_lines(lines, card, active, body_style, None, label_style);
}
fn append_wide_card_lines(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
active: bool,
width: u16,
) {
let label_style = role_label_style(card.role, active);
let body_style = role_body_style(card.role, active);
let title = card_header_title(card);
let title_text = format!("┌─ {title}");
lines.push(TranscriptVisualLine {
line: Line::from(Span::styled(
pad_wide_edge(title_text, width, '┐'),
label_style,
)),
copy_text: title,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 3,
activity_button: None,
});
append_card_body_lines(lines, card, active, body_style, Some(width), label_style);
lines.push(wide_bottom_visual_line(card, width, label_style, active));
}
fn append_card_body_lines(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
active: bool,
body_style: Style,
wide_width: Option<u16>,
border_style: Style,
) {
if card.role == TranscriptCardRole::SubagentBatch {
append_squad_body_lines(lines, card, active, wide_width, border_style);
return;
}
for metadata in &card.metadata {
push_text_line(
lines,
card,
format!(" {metadata}"),
MATRIX_GREEN.dim(active),
false,
wide_width,
border_style,
);
}
if wide_width.is_none() {
push_activity_button_line(lines, card, active);
}
if card.role == TranscriptCardRole::Assistant {
for line in &card.assistant_lines {
let copy_text = assistant_copy_projection(line);
if let Some(width) = wide_width {
let mut spans = Vec::with_capacity(line.spans.len().saturating_add(1));
spans.push(AssistantVisualSpan::styled(" ", MATRIX_GREEN.pane(), None));
spans.extend(line.spans.iter().cloned().map(|span| {
AssistantVisualSpan::styled(
span.text,
MATRIX_GREEN.display_role(span.role),
Some(span.role),
)
}));
push_wide_assistant_spans(lines, card, spans, true, width, border_style);
} else {
lines.push(TranscriptVisualLine {
line: indent_display_line(line.clone(), " "),
copy_text: format!(" {copy_text}"),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: code_block_gutter_copy_offset(line),
activity_button: None,
});
}
}
} else {
for body_line in card.body.lines() {
push_text_line(
lines,
card,
format!(" {body_line}"),
body_style,
true,
wide_width,
border_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,
MATRIX_GREEN.transcript_tool_body(),
true,
wide_width,
border_style,
);
for metadata in &child.metadata {
push_text_line(
lines,
card,
format!(" {metadata}"),
MATRIX_GREEN.dim(active),
true,
wide_width,
border_style,
);
}
for body_line in child.body.lines() {
push_text_line(
lines,
card,
format!(" {body_line}"),
MATRIX_GREEN.transcript_tool_body(),
true,
wide_width,
border_style,
);
}
}
}
fn append_squad_body_lines(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
_active: bool,
wide_width: Option<u16>,
border_style: Style,
) {
if wide_width.is_none() {
}
for child in &card.children {
let status_glyph = status_mark(child.status);
let status_style = child_status_style(child.status);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(" ", border_style));
spans.push(Span::styled(format!("{status_glyph:>2} "), status_style));
if !child.task_id.is_empty() {
spans.push(Span::styled(
format!("{} ", child.task_id),
MATRIX_GREEN.accent(_active),
));
}
if let Some(identity) = &child.identity {
spans.push(Span::styled(
format!("{identity} "),
MATRIX_GREEN.dim(_active),
));
}
let intent = if child.intent.is_empty() {
child.label.clone()
} else {
child.intent.clone()
};
spans.push(Span::styled(
intent.clone(),
MATRIX_GREEN.transcript_tool_body(),
));
let copy_text = format!(
" {} {} {} {}",
status_glyph,
child.task_id,
child.identity.as_deref().unwrap_or(""),
intent
);
if let Some(width) = wide_width {
for (index, row_spans) in wrap_spans_to_width(spans, wide_content_width(width))
.into_iter()
.enumerate()
{
lines.push(TranscriptVisualLine {
line: wide_body_line(row_spans, width, border_style),
copy_text: copy_text.clone(),
copy_byte_range: None,
copyable: true,
copy_continuation: index > 0,
visual_copy_column_offset: 2,
activity_button: None,
});
}
} else {
lines.push(TranscriptVisualLine {
line: Line::from(spans),
copy_text,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
});
}
if child.status == TranscriptCardStatus::Failed && !child.body.is_empty() {
for body_line in child.body.lines() {
push_text_line(
lines,
card,
format!(" └ {body_line}"),
MATRIX_GREEN.dim(false),
true,
wide_width,
border_style,
);
}
}
}
if wide_width.is_none() {
push_activity_button_line(lines, card, _active);
}
}
fn child_status_style(status: TranscriptCardStatus) -> Style {
match status {
TranscriptCardStatus::Success => MATRIX_GREEN.accent(false),
TranscriptCardStatus::Failed => MATRIX_GREEN.transcript_error_label(),
TranscriptCardStatus::Running => MATRIX_GREEN.transcript_tool_body(),
TranscriptCardStatus::Canceled => MATRIX_GREEN.muted(false),
TranscriptCardStatus::None => MATRIX_GREEN.muted(false),
}
}
fn push_text_line(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
text: String,
style: Style,
is_body: bool,
wide_width: Option<u16>,
wide_edge_style: Style,
) {
if let Some(width) = wide_width {
push_wide_spans(
lines,
card,
vec![Span::styled(text, style)],
is_body,
width,
wide_edge_style,
);
return;
}
lines.push(TranscriptVisualLine {
line: Line::from(Span::styled(text.clone(), style)),
copy_text: text,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
});
}
fn push_activity_button_line(
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
active: bool,
) {
let Some(activity_id) = card_activity_button_id(card) else {
return;
};
let button = ACTIVITY_BUTTON_LABEL;
let button_style = MATRIX_GREEN
.accent(active)
.add_modifier(Modifier::UNDERLINED);
let edge_style = MATRIX_GREEN.transcript_tool_body();
let activity_button = |start_column: u16| TranscriptActivityButton {
activity_id: activity_id.clone(),
start_column,
end_column: start_column.saturating_add(button.len() as u16),
};
lines.push(TranscriptVisualLine {
line: Line::from(vec![
Span::styled(" ", edge_style),
Span::styled(button.to_string(), button_style),
]),
copy_text: String::new(),
copy_byte_range: None,
copyable: true,
copy_continuation: true,
visual_copy_column_offset: 0,
activity_button: Some(activity_button(2)),
});
}
fn card_activity_button_id(card: &TranscriptCard) -> Option<ActivityId> {
matches!(
card.role,
TranscriptCardRole::Tool | TranscriptCardRole::SubagentBatch
)
.then(|| card.id.activity_id.clone())
.flatten()
}
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, wide_content_width(width))
.into_iter()
.enumerate()
{
let copy_text = spans_text(&row_spans);
lines.push(TranscriptVisualLine {
line: wide_body_line(row_spans, width, edge_style),
copy_text,
copy_byte_range: None,
copyable: true,
copy_continuation: index > 0,
visual_copy_column_offset: 2,
activity_button: None,
});
}
}
#[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, wide_content_width(width))
.into_iter()
.enumerate()
{
let copy_text = assistant_wide_copy_text(&row_spans);
let visual_copy_column_offset =
2u16.saturating_add(assistant_code_gutter_width(&row_spans));
lines.push(TranscriptVisualLine {
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,
copy_continuation: index > 0,
visual_copy_column_offset,
activity_button: None,
});
}
}
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_copy_projection(line: &DisplayLine) -> String {
if line
.spans
.iter()
.all(|span| span.role != DisplayRole::CodeBlockGutter)
{
return plain_projection(std::slice::from_ref(line));
}
line.spans
.iter()
.filter(|span| span.role != DisplayRole::CodeBlockGutter)
.map(|span| span.text.as_str())
.collect()
}
fn code_block_gutter_copy_offset(line: &DisplayLine) -> u16 {
display_width_to_u16(
line.spans
.iter()
.filter(|span| span.role == DisplayRole::CodeBlockGutter)
.map(|span| UnicodeWidthStr::width(span.text.as_str()))
.sum(),
)
}
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(
mut content_spans: Vec<Span<'static>>,
width: u16,
edge_style: Style,
) -> Line<'static> {
let mut spans = Vec::with_capacity(content_spans.len().saturating_add(3));
spans.push(Span::styled("│ ", edge_style));
let content_width = spans_display_width(&content_spans);
spans.append(&mut content_spans);
let total_without_padding = 2usize.saturating_add(content_width).saturating_add(1);
let padding = usize::from(width).saturating_sub(total_without_padding);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), edge_style));
}
spans.push(Span::styled("│", edge_style));
Line::from(spans)
}
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 unit_width > max_width {
continue;
}
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 unit_width > max_width {
continue;
}
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),
})
}
fn wide_content_width(width: u16) -> usize {
usize::from(width).saturating_sub(3).max(1)
}
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 blank_visual_line(_card: &TranscriptCard) -> TranscriptVisualLine {
TranscriptVisualLine {
line: Line::from(""),
copy_text: String::new(),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
}
}
fn separator_visual_line(_card: &TranscriptCard) -> TranscriptVisualLine {
TranscriptVisualLine {
line: Line::from(Span::styled(
TRANSCRIPT_TURN_SEPARATOR,
MATRIX_GREEN.dim(false).add_modifier(Modifier::DIM),
)),
copy_text: TRANSCRIPT_TURN_SEPARATOR.to_string(),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
}
}
fn wide_separator_visual_line(_card: &TranscriptCard, width: u16) -> TranscriptVisualLine {
TranscriptVisualLine {
line: Line::from(Span::styled(
pad_wide_edge("├─ user turn ".to_string(), width, '┤'),
MATRIX_GREEN.dim(false).add_modifier(Modifier::DIM),
)),
copy_text: TRANSCRIPT_TURN_SEPARATOR.to_string(),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
}
}
fn wide_bottom_visual_line(
card: &TranscriptCard,
width: u16,
style: Style,
active: bool,
) -> TranscriptVisualLine {
let width = usize::from(width.max(2));
let button = card_activity_button_id(card).map(|activity_id| {
let button_width = UnicodeWidthStr::width(ACTIVITY_BUTTON_LABEL);
let start_column = width.saturating_sub(1 + button_width) as u16;
TranscriptActivityButton {
activity_id,
start_column,
end_column: start_column.saturating_add(button_width as u16),
}
});
let line = if button.is_some() {
let button_style = MATRIX_GREEN
.accent(active)
.add_modifier(Modifier::UNDERLINED);
let button_width = UnicodeWidthStr::width(ACTIVITY_BUTTON_LABEL);
let left_edge_width = width.saturating_sub(1 + button_width + 1);
Line::from(vec![
Span::styled("└", style),
Span::styled("─".repeat(left_edge_width), style),
Span::styled(ACTIVITY_BUTTON_LABEL.to_string(), button_style),
Span::styled("┘", style),
])
} else {
Line::from(Span::styled(
format!("└{}┘", "─".repeat(width.saturating_sub(2))),
style,
))
};
TranscriptVisualLine {
line,
copy_text: String::new(),
copy_byte_range: None,
copyable: false,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: button,
}
}
fn pad_wide_edge(text: String, width: u16, end: char) -> String {
let width = usize::from(width.max(1));
let target_width = width.saturating_sub(1);
let mut text = truncate_to_display_width(&text, target_width);
let current = UnicodeWidthStr::width(text.as_str());
if current < target_width {
text.push_str(&"─".repeat(target_width - current));
}
text.push(end);
text
}
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 role_label_style(role: TranscriptCardRole, active: bool) -> Style {
match role {
TranscriptCardRole::Error => MATRIX_GREEN.transcript_error_label(),
TranscriptCardRole::Session
| TranscriptCardRole::Bash
| TranscriptCardRole::Diagnostic
| TranscriptCardRole::Compaction
| TranscriptCardRole::Empty => MATRIX_GREEN.muted(active),
TranscriptCardRole::Reasoning => MATRIX_GREEN.dim(active).add_modifier(Modifier::BOLD),
TranscriptCardRole::Tool | TranscriptCardRole::SubagentBatch => {
MATRIX_GREEN.accent(active).add_modifier(Modifier::BOLD)
}
TranscriptCardRole::UserPrompt | TranscriptCardRole::Assistant => {
MATRIX_GREEN.accent(active).add_modifier(Modifier::BOLD)
}
}
}
fn role_body_style(role: TranscriptCardRole, _active: bool) -> Style {
match role {
TranscriptCardRole::UserPrompt | TranscriptCardRole::Bash => {
MATRIX_GREEN.transcript_user_body()
}
TranscriptCardRole::Tool | TranscriptCardRole::SubagentBatch => {
MATRIX_GREEN.transcript_tool_body()
}
TranscriptCardRole::Error => MATRIX_GREEN.transcript_error_body(),
TranscriptCardRole::Session
| TranscriptCardRole::Diagnostic
| TranscriptCardRole::Compaction => MATRIX_GREEN.transcript_session_body(),
TranscriptCardRole::Reasoning => MATRIX_GREEN.dim(false),
TranscriptCardRole::Assistant | TranscriptCardRole::Empty => MATRIX_GREEN.pane(),
}
}
fn display_status_mark(card: &TranscriptCard) -> &'static str {
match card.role {
TranscriptCardRole::Assistant
| TranscriptCardRole::UserPrompt
| TranscriptCardRole::Bash
| TranscriptCardRole::Reasoning => "",
_ => 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 => "",
}
}
fn indent_display_line(line: DisplayLine, indent: &'static str) -> Line<'static> {
let mut spans = Vec::with_capacity(line.spans.len().saturating_add(1));
spans.push(Span::styled(indent, MATRIX_GREEN.pane()));
spans.extend(
line.spans
.into_iter()
.map(|span| Span::styled(span.text, MATRIX_GREEN.display_role(span.role))),
);
Line::from(spans)
}