use super::*;
pub(super) fn push_activity_card_text_box(
lines: &mut Vec<TranscriptVisualLine>,
text: &str,
style: Style,
width: u16,
body_style: Style,
) {
let available = activity_card_content_width(width);
if available < 5 {
push_activity_card_row(
lines,
vec![ActivityCardSegment::plain(text, style)],
width,
body_style,
);
push_activity_card_row(lines, Vec::new(), width, body_style);
return;
}
push_activity_card_row(
lines,
vec![ActivityCardSegment::plain(
format!("╭{}╮", "─".repeat(available - 2)),
style,
)],
width,
body_style,
);
if let Some(line) = lines.last_mut() {
line.copy_text.clear();
line.copyable = false;
}
let inner_width = available - 4;
for (index, wrapped) in
wrap_spans_to_width(vec![Span::styled(text.to_string(), style)], inner_width)
.into_iter()
.enumerate()
{
let text = spans_text(&wrapped);
let padding = inner_width.saturating_sub(UnicodeWidthStr::width(text.as_str()));
push_activity_card_row(
lines,
vec![ActivityCardSegment::plain(
format!("│ {text}{} │", " ".repeat(padding)),
style,
)],
width,
body_style,
);
if let Some(line) = lines.last_mut() {
line.copy_text = text;
line.copy_continuation = index > 0;
line.visual_copy_column_offset =
body_line_copy_offset(width, (ACTIVITY_CARD_CONTENT_INDENT + 2) as u16);
}
}
push_activity_card_row(
lines,
vec![ActivityCardSegment::plain(
format!("╰{}╯", "─".repeat(available - 2)),
style,
)],
width,
body_style,
);
if let Some(line) = lines.last_mut() {
line.copy_text.clear();
line.copyable = false;
}
push_activity_card_row(lines, Vec::new(), width, body_style);
if let Some(line) = lines.last_mut() {
line.copy_text.clear();
line.copyable = false;
}
}
pub(super) 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_activity_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_activity_card_row(
lines,
vec![
ActivityCardSegment::plain(
format!(
"{} {}{}{}",
activity_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(),
),
activity_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()
};
let intent_style = theme
.transcript_tool_body()
.bg(card_body_background(card.role, theme));
let intent = format!("Intent: {intent}");
push_activity_card_text_box(lines, &intent, intent_style, width, body_style);
} else {
push_activity_card_row(lines, Vec::new(), width, body_style);
push_activity_card_row(lines, Vec::new(), width, body_style);
}
let mut usage_title = Vec::new();
if let Some(selected) = selected
&& (selected.usage.is_some()
|| selected.subagent_display.context.is_some()
|| selected.subagent_display.started.is_some())
{
usage_title = format_subagent_usage(
selected.usage.unwrap_or_default(),
selected,
width,
body_style,
theme,
);
}
push_activity_card_box_edge(lines, width, body_style, theme, true, usage_title);
let mut activity_rows = selected
.into_iter()
.flat_map(|selected| &selected.live_tail)
.map(|tail| {
ActivityCardSegment::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(ActivityCardSegment::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_activity_card_feed_row(lines, Some(row), width, body_style, theme);
}
for _ in activity_row_count..state.subagent_card_rows() {
push_activity_card_feed_row(lines, None, width, body_style, theme);
}
push_activity_card_box_edge(lines, width, body_style, theme, false, Vec::new());
push_activity_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| {
ActivityCardSegment::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(
activity_card_content_width(width).saturating_sub(UnicodeWidthStr::width(open)),
);
push_activity_card_row(
lines,
vec![ActivityCardSegment::plain(spacer, body_style), open_target],
width,
body_style,
);
}
}
pub(super) fn push_activity_card_box_edge(
lines: &mut Vec<TranscriptVisualLine>,
width: u16,
body_style: Style,
theme: MissionControlTheme,
top: bool,
title: Vec<ActivityCardSegment>,
) {
let available = activity_card_content_width(width);
if available < 4 {
return;
}
let border_style = body_style.fg(theme.border_alternate());
let (left, right) = if top { ("╭", "╮") } else { ("╰", "╯") };
let mut segments = vec![ActivityCardSegment::plain(left, border_style)];
let mut remaining = available - 2;
if !title.is_empty() {
segments.push(ActivityCardSegment::plain(" ", body_style));
remaining -= 2;
for segment in title {
let text = truncate_to_display_width(&segment.text, remaining);
remaining = remaining.saturating_sub(UnicodeWidthStr::width(text.as_str()));
segments.push(ActivityCardSegment::plain(text, segment.style));
}
segments.push(ActivityCardSegment::plain(" ", body_style));
}
segments.push(ActivityCardSegment::plain(
format!("{}{right}", "─".repeat(remaining)),
border_style,
));
push_activity_card_row(lines, segments, width, body_style);
}
fn push_activity_card_box_row(
lines: &mut Vec<TranscriptVisualLine>,
row: Vec<ActivityCardSegment>,
width: u16,
body_style: Style,
row_style: Style,
theme: MissionControlTheme,
) {
let available = activity_card_content_width(width);
if available < 4 {
push_activity_card_row(lines, Vec::new(), width, body_style);
return;
}
let border_style = body_style.fg(theme.border_alternate());
let mut segments = vec![
ActivityCardSegment::plain("│", border_style),
ActivityCardSegment::plain(" ", row_style),
];
let mut remaining = available - 4;
for mut segment in row {
segment.text = truncate_to_display_width(&segment.text, remaining);
remaining = remaining.saturating_sub(UnicodeWidthStr::width(segment.text.as_str()));
segments.push(segment);
}
segments.push(ActivityCardSegment::plain(
" ".repeat(remaining + 1),
row_style,
));
segments.push(ActivityCardSegment::plain("│", border_style));
push_activity_card_row(lines, segments, width, body_style);
if let Some(line) = lines.last_mut() {
let interior_start = horizontal_padding(width).0 + ACTIVITY_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;
}
}
}
pub(super) fn activity_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<ActivityCardSegment> {
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 context = child.subagent_display.context.map_or_else(
|| "—".to_string(),
|context| {
let percent = context
.current_tokens
.saturating_mul(100)
.checked_div(context.max_tokens)
.map_or_else(|| "—".to_string(), |percent| format!("{percent}%"));
format!("{percent}/{}", compact_tokens(context.max_tokens as u64))
},
);
let elapsed = child.subagent_display.started.map_or_else(
|| "—".to_string(),
|_| {
let seconds = child.subagent_display.elapsed.as_secs();
if seconds >= 3600 {
format!("{}h{}m{}s", seconds / 3600, seconds / 60 % 60, seconds % 60)
} else if seconds >= 60 {
format!("{}m{}s", seconds / 60, seconds % 60)
} else {
format!("{seconds}s")
}
},
);
let tokens = |value| {
if child.usage.is_some() {
compact_tokens(value)
} else {
"—".to_string()
}
};
let fields = [
(
"Tks ",
format!("{}(in)", tokens(usage.whole_run.effective_input)),
style.fg(theme.transcript_user_color()),
),
(
"| ",
format!("{}(out)", tokens(usage.whole_run.output)),
style.fg(theme.text_accent()),
),
(
" • Cache ",
format!("{}(total)", cache(whole_run)),
cache_style,
),
("| ", format!("{}(latest)", cache(latest)), cache_style),
(" • Ctx ", context, style),
(" • Elapsed: ", elapsed, style),
];
let mut remaining = activity_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(ActivityCardSegment::plain(
label,
style.fg(theme.text_muted()),
));
segments.push(ActivityCardSegment::plain(value, field_style));
remaining -= used;
}
segments
}
pub(super) fn activity_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,
activity_card_status_glyph(child.status)
)
},
|name| {
format!(
"[{} {} {}]",
child.task_id,
name,
activity_card_status_glyph(child.status)
)
},
)
}
#[expect(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 = activity_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(ActivityCardSegment::plain(" ", body_style));
}
let selected = selected_id == Some(child.task_id.as_str());
let style = activity_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(activity_card_status_glyph(child.status));
segments.push(
ActivityCardSegment::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(ActivityCardSegment::plain(" ", body_style));
}
segments.push(ActivityCardSegment::plain(
format!("+{omitted} more"),
theme.dim(active).bg(card_body_background(
TranscriptCardRole::SubagentBatch,
theme,
)),
));
}
push_activity_card_row(lines, segments, width, body_style);
}
}
pub(super) struct ActivityCardSegment {
pub(super) text: String,
pub(super) style: Style,
pub(super) target: Option<TranscriptHitTarget>,
status_indicator: Option<(usize, Option<crate::output::ActivityId>)>,
}
impl ActivityCardSegment {
pub(super) 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
}
pub(super) 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 ACTIVITY_CARD_CONTENT_INDENT: usize = 2;
pub(super) fn activity_card_content_width(width: u16) -> usize {
body_wrap_width(width).saturating_sub(ACTIVITY_CARD_CONTENT_INDENT)
}
pub(super) fn push_activity_card_feed_row(
lines: &mut Vec<TranscriptVisualLine>,
segment: Option<ActivityCardSegment>,
width: u16,
body_style: Style,
theme: MissionControlTheme,
) {
push_activity_card_box_row(
lines,
segment.into_iter().collect(),
width,
body_style,
body_style.bg(theme.surface_background()),
theme,
);
}
fn push_activity_card_row(
lines: &mut Vec<TranscriptVisualLine>,
segments: Vec<ActivityCardSegment>,
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(ACTIVITY_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 {
image_row: None,
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),
}
}