use super::*;
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,
Some(node),
)
}
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 {
image_row: None,
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(),
}
}
pub(super) fn card_visual_lines_uncached(
state: &MissionControlState,
card: &TranscriptCard,
entry_index: usize,
active: bool,
width: u16,
prepend_blank: bool,
activity_node: Option<&crate::tui::activity::ActivityNode>,
) -> Vec<TranscriptVisualLine> {
let mut lines = Vec::new();
if prepend_blank {
lines.push(transcript_gap_visual_line(width, state.theme));
}
append_wide_card_lines(
state,
&mut lines,
card,
entry_index,
active,
width,
activity_node,
);
lines
}
fn append_image_preview_rows(
state: &MissionControlState,
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
width: u16,
) {
if !state.images.borrow().enabled() || card.id.entry_index == usize::MAX {
return;
}
let paths = match card.role {
TranscriptCardRole::Assistant if card.status == TranscriptCardStatus::Success => {
crate::rendering::markup::image_references(&card.body)
}
TranscriptCardRole::ViewImage => {
state
.transcript_absolute_index(card.id.entry_index)
.and_then(|index| state.transcript.activity_links().get(&index))
.and_then(|link| state.nodes.get(&link.activity_id))
.and_then(|node| node.tool_detail.as_ref())
.and_then(|detail| detail.params.get("path"))
.and_then(serde_json::Value::as_str)
.filter(|path| path.len() <= 4096)
.map(|path| vec![path.to_string()])
.unwrap_or_default()
}
_ => Vec::new(),
};
for path in paths
.into_iter()
.filter(|path| crate::tools::is_local_image_reference(path))
{
use crate::tui::images::{ImageRow, PREVIEW_PADDING, PREVIEW_ROWS};
for padded_row in 0..PREVIEW_ROWS + 2 * PREVIEW_PADDING {
let mut line = transcript_gap_visual_line(width, state.theme);
line.copyable = false;
line.image_row = padded_row
.checked_sub(PREVIEW_PADDING)
.filter(|row| *row < PREVIEW_ROWS)
.map(|row| ImageRow {
path: path.clone(),
row,
});
line.line = Line::styled(
" ".repeat(usize::from(width)),
state
.theme
.pane()
.bg(card_body_background(card.role, state.theme)),
);
lines.push(line);
}
}
}
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 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)
}
pub(super) fn append_wide_card_lines(
state: &MissionControlState,
lines: &mut Vec<TranscriptVisualLine>,
card: &TranscriptCard,
entry_index: usize,
active: bool,
width: u16,
activity_node: Option<&crate::tui::activity::ActivityNode>,
) {
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,
state,
card,
body_style,
width,
timestamp.as_deref(),
active,
);
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_trailing_width = usize::from(display_width > 1);
let timestamp_text = timestamp
.as_deref()
.map(|timestamp| {
truncate_to_display_width(timestamp, display_width - timestamp_trailing_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)
.saturating_sub(timestamp_trailing_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),
));
header_spans.push(Span::styled(
" ".repeat(timestamp_trailing_width),
Style::default().bg(header_background),
));
} else {
header_spans = pad_full_width_spans(header_spans, width);
}
lines.push(TranscriptVisualLine {
image_row: None,
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_body_blank(lines, card, width, theme);
append_card_body_lines(lines, state, card, active, body_style, width);
append_image_preview_rows(state, lines, card, width);
push_card_token_footer(lines, state, card, active, width, theme, activity_node);
}
pub(super) 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 {
image_row: None,
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(),
});
}
pub(super) fn push_card_token_footer(
lines: &mut Vec<TranscriptVisualLine>,
state: &MissionControlState,
card: &TranscriptCard,
active: bool,
width: u16,
theme: MissionControlTheme,
activity_node: Option<&crate::tui::activity::ActivityNode>,
) {
let Some(text) = card_token_footer(state, card, activity_node) else {
push_body_blank(lines, card, width, theme);
return;
};
let display_width = usize::from(width);
let right_padding = usize::from(width >= 3);
let text_width = UnicodeWidthStr::width(text.as_str());
if text_width.saturating_add(right_padding) > display_width {
push_body_blank(lines, card, width, theme);
return;
}
let background = card_body_background(card.role, theme);
let leading = display_width.saturating_sub(text_width + right_padding);
let style = theme
.muted(active)
.bg(background)
.add_modifier(Modifier::DIM);
lines.push(TranscriptVisualLine {
image_row: None,
status_indicators: Vec::new(),
line: Line::from(vec![
Span::styled(" ".repeat(leading), Style::default().bg(background)),
Span::styled(text, style),
Span::styled(" ".repeat(right_padding), 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 card_token_footer(
state: &MissionControlState,
card: &TranscriptCard,
activity_node: Option<&crate::tui::activity::ActivityNode>,
) -> Option<String> {
let estimate =
|text: &str| (!text.is_empty()).then(|| crate::context::estimate_text_tokens(text) as u64);
match card.role {
TranscriptCardRole::UserPrompt => estimate(&card.body)
.map(|tokens| format!("≈{} in", crate::tui::usage_format::compact_tokens(tokens))),
TranscriptCardRole::Assistant => estimate(&card.body)
.map(|tokens| format!("≈{} out", crate::tui::usage_format::compact_tokens(tokens))),
TranscriptCardRole::Bash
| TranscriptCardRole::Read
| TranscriptCardRole::Grep
| TranscriptCardRole::AstGrep
| TranscriptCardRole::Find
| TranscriptCardRole::ListFiles
| TranscriptCardRole::Web
| TranscriptCardRole::MagiControl
| TranscriptCardRole::Tool
| TranscriptCardRole::Write
| TranscriptCardRole::HashEdit
| TranscriptCardRole::ViewImage => {
let detail = activity_node
.and_then(|node| node.tool_detail.as_ref())
.or_else(|| {
state
.transcript_absolute_index(card.id.entry_index)
.and_then(|index| state.transcript.activity_links().get(&index))
.and_then(|link| state.nodes.get(&link.activity_id))
.and_then(|node| node.tool_detail.as_ref())
})?;
let input = estimate(detail.output.as_ref());
let output = serde_json::to_string(&detail.params)
.ok()
.and_then(|params| estimate(¶ms));
match (output, input) {
(Some(output), Some(input)) => Some(format!(
"≈{} out · ≈{} in",
crate::tui::usage_format::compact_tokens(output),
crate::tui::usage_format::compact_tokens(input)
)),
(Some(tokens), None) => Some(format!(
"≈{} out",
crate::tui::usage_format::compact_tokens(tokens)
)),
(None, Some(tokens)) => Some(format!(
"≈{} in",
crate::tui::usage_format::compact_tokens(tokens)
)),
(None, None) => None,
}
}
_ => None,
}
}
pub(super) fn horizontal_padding(width: u16) -> (usize, usize) {
if width >= 3 { (1, 1) } else { (0, 0) }
}
pub(super) fn user_prompt_horizontal_padding(width: u16) -> usize {
usize::from(width.saturating_sub(1) / 2).min(2)
}
pub(super) fn body_wrap_width(width: u16) -> usize {
let (left, right) = horizontal_padding(width);
usize::from(width)
.saturating_sub(left)
.saturating_sub(right)
.max(1)
}