use super::*;
const MIN_BLOCK_HEADER_WIDTH: u16 = 80;
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));
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);
}
if card.role == TranscriptCardRole::UserPrompt && card.title.is_empty() {
return "User".to_string();
}
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
.metadata()
.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));
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 token_text = card_token_text(state, card, activity_node);
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 leading_width = if status.is_empty() {
leading_width + prefix_limit.saturating_sub(leading_width).min(2)
} else {
leading_width
};
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 (name, details) = title.split_once(" • ").unwrap_or((&title, ""));
let (name, details) = match card.role {
TranscriptCardRole::Web => ("Web", title.strip_prefix("Web").unwrap_or(&title).trim()),
TranscriptCardRole::Assistant => (
"Agent",
title.strip_prefix("Agent").unwrap_or(&title).trim(),
),
TranscriptCardRole::AstGrep if name != "AST Grep" => (
"AST Grep",
title.strip_prefix("AST ").unwrap_or(&title).trim(),
),
_ => (name, details),
};
let details_text = if details.is_empty() {
String::new()
} else {
format!(" • {details}")
};
let metadata_width = UnicodeWidthStr::width(details_text.as_str()).max(
token_text
.as_deref()
.map_or(0, |text| UnicodeWidthStr::width(text) + 3),
);
let name_width = title_width.saturating_sub(metadata_width);
let block_rows = (width >= MIN_BLOCK_HEADER_WIDTH)
.then(|| super::block_title::block_title_rows(name, name_width))
.flatten();
let (title_text, title_bottom, title_copy, title_boundaries) = match block_rows {
Some([top, bottom]) => {
let mut boundaries = copy_byte_boundaries_for_span(&top, name);
append_copy_byte_boundaries(
&mut boundaries,
©_byte_boundaries_for_span(&details_text, &details_text),
name.len(),
);
(
format!("{top}{details_text}"),
Some(bottom),
format!("{name}{details_text}"),
boundaries,
)
}
None => {
let text = truncate_to_display_width(&title, title_width);
let boundaries = copy_byte_boundaries_for_span(&text, &text);
(text.clone(), None, text, boundaries)
}
};
let header_background = card_header_background(card.role, theme);
let status_text = if status_visible {
format!(
"{status}{}",
if content_width > status_width {
" "
} else {
""
}
)
} else {
String::new()
};
let copy_text = format!("{status_text}{title_copy}");
let mut copy_boundaries = copy_byte_boundaries_for_span(&status_text, &status_text);
append_copy_byte_boundaries(&mut copy_boundaries, &title_boundaries, status_text.len());
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);
}
if title_bottom.is_some() {
push_styled_blank(lines, width, header_background);
}
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: copy_boundaries,
hit_regions: Vec::new(),
});
if title_bottom.is_some() || token_text.is_some() {
let title_start = leading_width + UnicodeWidthStr::width(status_text.as_str());
let mut spans = vec![Span::styled(" ".repeat(title_start), label_style)];
let token_start = if let Some(title_bottom) = title_bottom {
let token_start = title_start + UnicodeWidthStr::width(title_bottom.as_str()) + 3;
spans.push(Span::styled(title_bottom, label_style));
token_start
} else {
title_start + 2
};
if let Some(token_text) = token_text {
let token_text = truncate_to_display_width(
&token_text,
display_width.saturating_sub(token_start + 1),
);
let padding = token_start.saturating_sub(spans_display_width(&spans));
spans.push(Span::styled(" ".repeat(padding), label_style));
spans.push(Span::styled(
token_text,
theme
.muted(active)
.bg(header_background)
.add_modifier(Modifier::DIM),
));
}
lines.push(TranscriptVisualLine {
image_row: None,
status_indicators: Vec::new(),
line: Line::from(pad_full_width_spans(spans, width)),
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(),
});
}
if card.role == TranscriptCardRole::UserPrompt {
append_user_prompt_lines(lines, state, card, body_style, width);
return;
}
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_body_blank(lines, card, width, theme);
}
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(),
});
}
fn card_token_text(
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::CodeMode
| 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 = if card.role == TranscriptCardRole::CodeMode {
detail.metadata["code_mode_response_token_estimate"].as_u64()
} else {
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 body_wrap_width(width: u16) -> usize {
let (left, right) = horizontal_padding(width);
usize::from(width)
.saturating_sub(left)
.saturating_sub(right)
.max(1)
}
pub(super) fn body_text_padding(width: u16) -> (usize, usize) {
let (left, right) = horizontal_padding(width);
let indent = body_wrap_width(width).saturating_sub(1).min(2);
(left + indent, right)
}
pub(super) fn body_text_wrap_width(width: u16) -> usize {
let (left, right) = body_text_padding(width);
usize::from(width).saturating_sub(left + right).max(1)
}