use super::{
MissionControlState,
modal::{SubagentViewerState, SubagentViewerTabState},
};
use crate::{
output::{ActivityId, ActivityKind},
tui::{normalize_inline_separators, transcript, transcript_cards::TranscriptVisualLine},
};
use std::cell::Ref;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, PartialEq, Eq)]
struct SubagentViewerTimelineKey {
task_id: ActivityId,
activity_revision: Option<u64>,
theme_revision: u64,
width: u16,
}
#[derive(Debug, Default)]
pub(crate) struct SubagentViewerTimelineCache {
key: Option<SubagentViewerTimelineKey>,
pub(crate) lines: Vec<TranscriptVisualLine>,
pub(crate) rows: usize,
row_offsets: Vec<usize>,
tabs_key: Option<(ActivityId, usize)>,
tabs: Vec<ActivityId>,
cards: HashMap<ActivityId, CachedViewerCard>,
node_count: usize,
}
impl SubagentViewerTimelineCache {
pub(crate) fn visible_line_range(
&self,
offset: usize,
height: u16,
) -> (std::ops::Range<usize>, u16) {
let start = self
.row_offsets
.partition_point(|row| *row <= offset)
.saturating_sub(1);
let end = self
.row_offsets
.partition_point(|row| *row < offset.saturating_add(usize::from(height)));
let skipped_rows = offset.saturating_sub(self.row_offsets.get(start).copied().unwrap_or(0));
(
start.min(self.lines.len())..end.min(self.lines.len()),
skipped_rows.min(u16::MAX as usize) as u16,
)
}
}
#[derive(Debug)]
struct CachedViewerCard {
revision: u64,
theme_revision: u64,
width: u16,
prepend_blank: bool,
lines: Rc<Vec<TranscriptVisualLine>>,
original_line_count: usize,
}
pub(crate) const MAX_SUBAGENT_VIEWER_TIMELINE_LINES: usize = 256;
const MAX_SUBAGENT_VIEWER_ACTIVITY_NODES: usize = 128;
const MAX_SUBAGENT_VIEWER_LINE_BYTES: usize = 2 * 1024;
const MAX_SUBAGENT_VIEWER_CARD_BYTES: usize = 512 * 1024;
#[derive(Clone)]
struct ViewerCardBlock {
lines: Rc<Vec<TranscriptVisualLine>>,
original_line_count: usize,
}
impl From<Vec<TranscriptVisualLine>> for ViewerCardBlock {
fn from(lines: Vec<TranscriptVisualLine>) -> Self {
let original_line_count = lines.len();
let (lines, _) = retain_oversized_activity_block(lines, MAX_SUBAGENT_VIEWER_TIMELINE_LINES);
Self {
lines: Rc::new(lines),
original_line_count,
}
}
}
impl Default for SubagentViewerTabState {
fn default() -> Self {
Self {
offset: 0,
follow_live: true,
}
}
}
impl MissionControlState {
pub(super) fn invalidate_subagent_viewer_tabs(&mut self) {
self.subagent_viewer_timeline_cache.get_mut().tabs_key = None;
}
pub(crate) fn cached_subagent_viewer_timeline(
&self,
task_id: &ActivityId,
width: u16,
) -> Ref<'_, SubagentViewerTimelineCache> {
let width = width.max(1);
let key = SubagentViewerTimelineKey {
task_id: task_id.clone(),
activity_revision: self.nodes.get(task_id).map(|node| node.update_sequence),
theme_revision: self.theme_revision(),
width,
};
let refresh = {
let mut cache = self.subagent_viewer_timeline_cache.borrow_mut();
let removed_nodes = self.nodes.len() < cache.node_count;
cache.node_count = self.nodes.len();
if removed_nodes {
cache.cards.clear();
}
cache.key.as_ref() != Some(&key) || removed_nodes
};
if refresh {
let lines = subagent_viewer_visual_lines(self, task_id, width);
let mut rows = 0usize;
let row_offsets = lines
.iter()
.map(|line| {
let start = rows;
rows += crate::tui::transcript_cards::visual_line_rows(line, width);
start
})
.collect();
let mut cache = self.subagent_viewer_timeline_cache.borrow_mut();
cache.row_offsets = row_offsets;
cache.key = Some(key);
cache.lines = lines;
cache.rows = rows.max(1);
}
Ref::map(self.subagent_viewer_timeline_cache.borrow(), |cache| cache)
}
pub(crate) fn subagent_viewer_visible(&self) -> bool {
self.modals.subagent_viewer.is_some()
}
pub(crate) fn subagent_viewer_tabs(&self) -> Vec<ActivityId> {
let Some(viewer) = self.modals.subagent_viewer.as_ref() else {
return Vec::new();
};
self.subagent_viewer_tabs_for_batch(&viewer.batch_id)
}
pub(crate) fn open_subagent_viewer(
&mut self,
batch_id: ActivityId,
task_id: ActivityId,
) -> bool {
if self.subagent_batch_for_task(&task_id).as_ref() != Some(&batch_id) {
return false;
}
*self.subagent_viewer_timeline_cache.get_mut() = Default::default();
let tabs = self.subagent_viewer_tabs_for_batch(&batch_id);
let Some(selected_task_id) = tabs
.iter()
.find(|candidate| candidate.as_str() == task_id.as_str())
.cloned()
.or_else(|| tabs.first().cloned())
else {
return false;
};
let mut tab_states = HashMap::with_capacity(tabs.len());
for tab in tabs {
tab_states.insert(tab, SubagentViewerTabState::default());
}
self.modals.subagent_viewer = Some(SubagentViewerState {
batch_id,
selected_task_id,
tab_states,
});
true
}
pub(crate) fn close_subagent_viewer(&mut self) -> bool {
self.modals.subagent_viewer.take().is_some()
}
pub(crate) fn subagent_viewer_selected_task_id(&self) -> Option<ActivityId> {
let viewer = self.modals.subagent_viewer.as_ref()?;
let tabs = self.subagent_viewer_tabs();
tabs.iter()
.find(|task_id| *task_id == &viewer.selected_task_id)
.cloned()
.or_else(|| tabs.first().cloned())
}
pub(crate) fn select_subagent_viewer_tab(&mut self, index: usize) -> bool {
let tabs = self.subagent_viewer_tabs();
let Some(task_id) = tabs.get(index).cloned() else {
return false;
};
let Some(viewer) = self.modals.subagent_viewer.as_mut() else {
return false;
};
if viewer.selected_task_id == task_id {
return false;
}
viewer.selected_task_id = task_id;
true
}
pub(crate) fn select_next_subagent_viewer_tab(&mut self) -> bool {
let tabs = self.subagent_viewer_tabs();
let Some(selected_task_id) = self.subagent_viewer_selected_task_id() else {
return false;
};
let Some(index) = tabs.iter().position(|task_id| task_id == &selected_task_id) else {
return false;
};
let next_index = if index + 1 == tabs.len() {
0
} else {
index + 1
};
self.select_subagent_viewer_tab(next_index)
}
pub(crate) fn select_previous_subagent_viewer_tab(&mut self) -> bool {
let tabs = self.subagent_viewer_tabs();
let Some(selected_task_id) = self.subagent_viewer_selected_task_id() else {
return false;
};
let Some(index) = tabs.iter().position(|task_id| task_id == &selected_task_id) else {
return false;
};
let previous_index = if index == 0 {
tabs.len() - 1
} else {
index - 1
};
self.select_subagent_viewer_tab(previous_index)
}
pub(crate) fn subagent_viewer_scroll_offset(
&self,
visible_rows: u16,
wrap_width: u16,
) -> usize {
let Some(task_id) = self.subagent_viewer_selected_task_id() else {
return 0;
};
let overflow = self.subagent_viewer_overflow(&task_id, visible_rows, wrap_width);
let tab = self
.modals
.subagent_viewer
.as_ref()
.and_then(|viewer| viewer.tab_states.get(&task_id));
tab.filter(|tab| !tab.follow_live)
.map(|tab| tab.offset.min(overflow))
.unwrap_or(overflow)
}
pub(crate) fn scroll_subagent_viewer_up(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let Some(task_id) = self.subagent_viewer_selected_task_id() else {
return false;
};
let overflow = self.subagent_viewer_overflow(&task_id, visible_rows, wrap_width);
let Some(viewer) = self.modals.subagent_viewer.as_mut() else {
return false;
};
let tab = viewer
.tab_states
.entry(task_id)
.or_insert_with(SubagentViewerTabState::default);
let before_offset = if tab.follow_live {
overflow
} else {
tab.offset.min(overflow)
};
let before_follow = tab.follow_live;
tab.offset = before_offset
.saturating_sub(usize::from(rows))
.min(overflow);
tab.follow_live = false;
before_offset != tab.offset || before_follow != tab.follow_live
}
pub(crate) fn scroll_subagent_viewer_down(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let Some(task_id) = self.subagent_viewer_selected_task_id() else {
return false;
};
let overflow = self.subagent_viewer_overflow(&task_id, visible_rows, wrap_width);
let Some(viewer) = self.modals.subagent_viewer.as_mut() else {
return false;
};
let tab = viewer
.tab_states
.entry(task_id)
.or_insert_with(SubagentViewerTabState::default);
let before = if tab.follow_live {
overflow
} else {
tab.offset.min(overflow)
};
let before_follow = tab.follow_live;
let after = if tab.follow_live {
tab.offset = overflow;
overflow
} else {
tab.offset = before.saturating_add(usize::from(rows)).min(overflow);
tab.follow_live = tab.offset >= overflow;
tab.offset
};
before != after || before_follow != tab.follow_live
}
pub(crate) fn home_subagent_viewer(&mut self) -> bool {
let Some(task_id) = self.subagent_viewer_selected_task_id() else {
return false;
};
let Some(viewer) = self.modals.subagent_viewer.as_mut() else {
return false;
};
let tab = viewer
.tab_states
.entry(task_id)
.or_insert_with(SubagentViewerTabState::default);
let changed = tab.offset != 0 || tab.follow_live;
tab.offset = 0;
tab.follow_live = false;
changed
}
pub(crate) fn end_subagent_viewer(&mut self, visible_rows: u16, wrap_width: u16) -> bool {
let Some(task_id) = self.subagent_viewer_selected_task_id() else {
return false;
};
let overflow = self.subagent_viewer_overflow(&task_id, visible_rows, wrap_width);
let Some(viewer) = self.modals.subagent_viewer.as_mut() else {
return false;
};
let tab = viewer
.tab_states
.entry(task_id)
.or_insert_with(SubagentViewerTabState::default);
let changed = tab.offset != overflow || !tab.follow_live;
tab.offset = overflow;
tab.follow_live = true;
changed
}
fn subagent_viewer_overflow(
&self,
task_id: &ActivityId,
visible_rows: u16,
wrap_width: u16,
) -> usize {
self.cached_subagent_viewer_timeline(task_id, wrap_width)
.rows
.saturating_sub(usize::from(visible_rows.max(1)))
}
fn subagent_batch_for_task(&self, task_id: &ActivityId) -> Option<ActivityId> {
let task = self.nodes.get(task_id)?;
if task.kind != ActivityKind::SubagentTask {
return None;
}
let mut current = task.parent_id.clone()?;
let mut visited = HashSet::new();
loop {
if !visited.insert(current.clone()) {
return None;
}
let node = self.nodes.get(¤t)?;
if node.kind == ActivityKind::SubagentBatch
|| (node.kind == ActivityKind::Tool
&& self.nodes.values().any(|child| {
child.kind == ActivityKind::SubagentTask
&& child.parent_id.as_ref() == Some(¤t)
}))
{
return Some(current);
}
current = node.parent_id.clone()?;
}
}
fn subagent_viewer_tabs_for_batch(&self, batch_id: &ActivityId) -> Vec<ActivityId> {
let key = (batch_id.clone(), self.nodes.len());
if self
.subagent_viewer_timeline_cache
.borrow()
.tabs_key
.as_ref()
== Some(&key)
{
return self.subagent_viewer_timeline_cache.borrow().tabs.clone();
}
let mut seen = HashSet::new();
let mut tabs = self
.children
.get(batch_id)
.into_iter()
.flatten()
.filter(|child_id| {
self.nodes.get(*child_id).is_some_and(|node| {
node.kind == ActivityKind::SubagentTask
&& node.parent_id.as_ref() == Some(batch_id)
})
})
.filter(|child_id| seen.insert((*child_id).clone()))
.cloned()
.collect::<Vec<_>>();
let mut fallback = self
.nodes
.values()
.filter(|node| {
node.kind == ActivityKind::SubagentTask
&& node.parent_id.as_ref() == Some(batch_id)
&& !seen.contains(&node.id)
})
.map(|node| node.id.clone())
.collect::<Vec<_>>();
fallback.sort();
tabs.extend(fallback);
let mut cache = self.subagent_viewer_timeline_cache.borrow_mut();
cache.tabs_key = Some(key);
cache.tabs = tabs.clone();
tabs
}
pub(crate) fn subagent_viewer_tab_label(
&self,
task_id: &ActivityId,
max_width: usize,
) -> String {
let short_id = task_id
.as_str()
.rsplit('/')
.next()
.filter(|segment| !segment.is_empty())
.unwrap_or(task_id.as_str());
let safe_short_id = safe_line(short_id).unwrap_or_else(|| "<task>".to_string());
let qualifier = self.nodes.get(task_id).and_then(|node| {
node.metadata
.fields
.iter()
.find(|(key, value)| {
matches!(key.as_str(), "agent" | "identity") && !value.trim().is_empty()
})
.map(|(_, value)| value.as_str())
});
let raw = qualifier
.map(|value| format!("{safe_short_id} • {value}"))
.unwrap_or_else(|| safe_short_id.clone());
let safe = safe_line(&raw).unwrap_or(safe_short_id);
crate::tui::activity::truncate_display_width(&safe, max_width.max(1))
}
}
pub(crate) fn subagent_viewer_visual_lines(
state: &MissionControlState,
task_id: &ActivityId,
width: u16,
) -> Vec<TranscriptVisualLine> {
let width = width.max(1);
let Some(task) = state.nodes.get(task_id) else {
return vec![
crate::tui::transcript_cards::activity_viewer_notice_visual_line(
state,
width,
"No live activity available",
),
];
};
let (activity_ids, omitted_nodes) = subagent_viewer_activity_ids_with_omitted(state, task_id);
let mut blocks = Vec::with_capacity(activity_ids.len().saturating_add(1));
let mut retained = HashSet::from([task_id.clone()]);
retained.extend(activity_ids.iter().cloned());
blocks.push(cached_activity_card_lines(state, task, width, false));
for activity_id in activity_ids {
let Some(node) = state.nodes.get(&activity_id) else {
continue;
};
blocks.push(cached_activity_card_lines(state, node, width, true));
}
state
.subagent_viewer_timeline_cache
.borrow_mut()
.cards
.retain(|id, _| retained.contains(id));
retain_viewer_blocks(state, width, blocks, omitted_nodes)
}
fn cached_activity_card_lines(
state: &MissionControlState,
node: &crate::tui::activity::ActivityNode,
width: u16,
prepend_blank: bool,
) -> ViewerCardBlock {
let mut cache = state.subagent_viewer_timeline_cache.borrow_mut();
if let Some(card) = cache.cards.get(&node.id)
&& card.revision == node.update_sequence
&& card.theme_revision == state.theme_revision()
&& card.width == width
&& card.prepend_blank == prepend_blank
{
return ViewerCardBlock {
lines: Rc::clone(&card.lines),
original_line_count: card.original_line_count,
};
}
let block = ViewerCardBlock::from(crate::tui::transcript_cards::activity_card_visual_lines(
state,
node,
width,
prepend_blank,
));
cache.cards.remove(&node.id);
if viewer_card_storage_bytes(&block.lines) <= MAX_SUBAGENT_VIEWER_CARD_BYTES {
cache.cards.insert(
node.id.clone(),
CachedViewerCard {
revision: node.update_sequence,
theme_revision: state.theme_revision(),
width,
prepend_blank,
lines: Rc::clone(&block.lines),
original_line_count: block.original_line_count,
},
);
}
block
}
fn retain_viewer_blocks(
state: &MissionControlState,
width: u16,
mut blocks: Vec<ViewerCardBlock>,
omitted_nodes: usize,
) -> Vec<TranscriptVisualLine> {
let total_lines = blocks
.iter()
.map(|block| block.original_line_count)
.sum::<usize>();
if omitted_nodes == 0 && total_lines <= MAX_SUBAGENT_VIEWER_TIMELINE_LINES {
return blocks
.iter()
.flat_map(|block| block.lines.iter().cloned())
.collect();
}
let root = blocks.remove(0);
let mut dropped_nodes = omitted_nodes;
let mut dropped_lines = 0usize;
let root_capacity = MAX_SUBAGENT_VIEWER_TIMELINE_LINES.saturating_sub(1);
let (mut root, root_omitted_lines) = retain_cached_activity_block(&root, root_capacity);
dropped_lines = dropped_lines.saturating_add(root_omitted_lines);
let mut remaining = MAX_SUBAGENT_VIEWER_TIMELINE_LINES
.saturating_sub(root.len())
.saturating_sub(1);
let mut retained_descendants = Vec::new();
let mut reverse_blocks = blocks.into_iter().rev();
let mut newest = true;
while let Some(block) = reverse_blocks.next() {
let original_len = block.original_line_count;
let fits = newest || block.lines.len() <= remaining;
let max_lines = if newest {
remaining
} else {
MAX_SUBAGENT_VIEWER_TIMELINE_LINES
};
newest = false;
let (block, omitted_lines) = if fits {
retain_cached_activity_block(&block, max_lines)
} else {
(Vec::new(), original_len)
};
if block.len() <= remaining && (!block.is_empty() || original_len == 0) {
remaining = remaining.saturating_sub(block.len());
dropped_lines = dropped_lines.saturating_add(omitted_lines);
retained_descendants.push(block);
continue;
}
dropped_nodes = dropped_nodes.saturating_add(1);
dropped_lines = dropped_lines.saturating_add(original_len);
for older_block in reverse_blocks {
dropped_nodes = dropped_nodes.saturating_add(1);
dropped_lines = dropped_lines.saturating_add(older_block.original_line_count);
}
break;
}
let mut retained = Vec::with_capacity(
root.len()
.saturating_add(1)
.saturating_add(MAX_SUBAGENT_VIEWER_TIMELINE_LINES.saturating_sub(remaining)),
);
retained.append(&mut root);
retained.push(
crate::tui::transcript_cards::activity_viewer_notice_visual_line(
state,
width,
viewer_omission_text(dropped_nodes, dropped_lines),
),
);
for block in retained_descendants.into_iter().rev() {
retained.extend(block);
}
retained
}
fn retain_cached_activity_block(
block: &ViewerCardBlock,
max_lines: usize,
) -> (Vec<TranscriptVisualLine>, usize) {
let lines = &block.lines;
let retained = if lines.len() <= max_lines {
lines.as_ref().clone()
} else if max_lines == 0 {
Vec::new()
} else {
let prefix_len = activity_card_header_prefix_len(lines);
if prefix_len > max_lines {
lines
.iter()
.find(|line| !is_blank_visual_line(line))
.cloned()
.map_or_else(|| lines[..max_lines].to_vec(), |header| vec![header])
} else {
let (start, end) = newest_body_range(&lines[prefix_len..], max_lines - prefix_len);
lines[..prefix_len]
.iter()
.chain(&lines[prefix_len + start..prefix_len + end])
.cloned()
.collect()
}
};
let omitted = block.original_line_count.saturating_sub(retained.len());
(retained, omitted)
}
fn allocated_vector_bytes<T>(values: &Vec<T>) -> usize {
values.capacity() * std::mem::size_of::<T>()
}
fn viewer_card_storage_bytes(lines: &Vec<TranscriptVisualLine>) -> usize {
use crate::tui::transcript_cards::TranscriptHitTarget;
let mut bytes = lines.capacity() * std::mem::size_of::<TranscriptVisualLine>();
for line in lines {
bytes += line.copy_text.capacity()
+ line.line.spans.capacity() * std::mem::size_of::<ratatui::text::Span<'static>>()
+ line
.line
.spans
.iter()
.map(|span| match &span.content {
std::borrow::Cow::Owned(text) => text.capacity(),
std::borrow::Cow::Borrowed(_) => 0,
})
.sum::<usize>()
+ line.visual_copy_byte_boundaries.capacity() * std::mem::size_of::<usize>()
+ allocated_vector_bytes(&line.hit_regions)
+ line.status_indicators.capacity() * std::mem::size_of::<(u16, Option<ActivityId>)>();
for region in &line.hit_regions {
bytes += match ®ion.target {
TranscriptHitTarget::SelectTask { batch_id, task_id }
| TranscriptHitTarget::OpenTranscript { batch_id, task_id } => {
batch_id.as_str().len() + task_id.as_str().len()
}
};
}
bytes += line
.status_indicators
.iter()
.filter_map(|(_, id)| id.as_ref())
.map(|id| id.as_str().len())
.sum::<usize>();
}
bytes
}
fn retain_oversized_activity_block(
block: Vec<TranscriptVisualLine>,
max_lines: usize,
) -> (Vec<TranscriptVisualLine>, usize) {
let block_len = block.len();
if block_len <= max_lines {
return (block, 0);
}
if max_lines == 0 {
return (Vec::new(), block_len);
}
let prefix_len = activity_card_header_prefix_len(&block);
if prefix_len > max_lines {
let retained = block
.iter()
.find(|line| !is_blank_visual_line(line))
.cloned()
.map_or_else(|| block[..max_lines].to_vec(), |header| vec![header]);
let omitted = block_len.saturating_sub(retained.len());
return (retained, omitted);
}
let body_capacity = max_lines.saturating_sub(prefix_len);
let (body_start, body_end) = newest_body_range(&block[prefix_len..], body_capacity);
let mut retained = Vec::with_capacity(max_lines);
retained.extend(block.into_iter().enumerate().filter_map(|(index, line)| {
(index < prefix_len || (prefix_len + body_start..prefix_len + body_end).contains(&index))
.then_some(line)
}));
let omitted = block_len.saturating_sub(retained.len());
(retained, omitted)
}
fn activity_card_header_prefix_len(block: &[TranscriptVisualLine]) -> usize {
block
.iter()
.position(|line| !is_blank_visual_line(line))
.map_or_else(
|| usize::from(!block.is_empty()),
|index| index.saturating_add(1),
)
}
fn newest_body_range(body: &[TranscriptVisualLine], capacity: usize) -> (usize, usize) {
if capacity == 0 || body.is_empty() {
return (body.len(), body.len());
}
let mut end = body.len();
if body.last().is_some_and(is_blank_visual_line) && capacity == 1 {
end = end.saturating_sub(1);
}
let newest = body.iter().rposition(|line| !is_blank_visual_line(line));
if let Some(newest) = newest {
end = end.max(newest.saturating_add(1));
}
let start = end.saturating_sub(capacity);
(start, end)
}
fn is_blank_visual_line(line: &TranscriptVisualLine) -> bool {
line.copy_text.trim().is_empty()
}
fn viewer_omission_text(omitted_nodes: usize, omitted_lines: usize) -> String {
match (omitted_nodes, omitted_lines) {
(0, 0) => "… earlier activity omitted …".to_string(),
(0, lines) => format!("… {lines} earlier activity lines omitted …"),
(nodes, 0) => {
let label = if nodes == 1 { "activity" } else { "activities" };
format!("… {nodes} earlier {label} omitted …")
}
(nodes, lines) => {
let label = if nodes == 1 { "activity" } else { "activities" };
format!("… {nodes} earlier {label} and {lines} lines omitted …")
}
}
}
fn subagent_viewer_activity_ids_with_omitted(
state: &MissionControlState,
task_id: &ActivityId,
) -> (Vec<ActivityId>, usize) {
let mut children: HashMap<&ActivityId, Vec<&ActivityId>> = HashMap::new();
for node in state.nodes.values() {
if let Some(parent) = node.parent_id.as_ref() {
children.entry(parent).or_default().push(&node.id);
}
}
let mut visited = HashSet::from([task_id]);
let mut pending = vec![task_id];
let mut newest = BTreeSet::new();
let mut count = 0usize;
while let Some(parent) = pending.pop() {
for child in children.get(parent).into_iter().flatten().copied() {
if !visited.insert(child) {
continue;
}
pending.push(child);
let node = &state.nodes[child];
count = count.saturating_add(1);
newest.insert((node.own_update_sequence, node.update_sequence, child));
if newest.len() > MAX_SUBAGENT_VIEWER_ACTIVITY_NODES {
newest.pop_first();
}
}
}
let omitted = count.saturating_sub(MAX_SUBAGENT_VIEWER_ACTIVITY_NODES);
let ids = newest.into_iter().map(|(_, _, id)| id.clone()).collect();
(ids, omitted)
}
fn safe_line(value: &str) -> Option<String> {
let value = normalize_inline_separators(&transcript::sanitize_preview(
&crate::output::redact_sensitive_text(value.trim()),
));
if value.is_empty() {
return None;
}
Some(bound_line(&value))
}
fn bound_line(value: &str) -> String {
if value.len() <= MAX_SUBAGENT_VIEWER_LINE_BYTES {
return value.to_string();
}
let marker = "…";
let limit = MAX_SUBAGENT_VIEWER_LINE_BYTES.saturating_sub(marker.len());
let mut out = String::new();
for grapheme in value.graphemes(true) {
if out.len().saturating_add(grapheme.len()) > limit {
break;
}
out.push_str(grapheme);
}
out.push_str(marker);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus,
};
use ratatui::text::{Line, Span};
fn viewer_state(task_count: usize) -> (MissionControlState, ActivityId, Vec<ActivityId>) {
assert!(task_count > 0);
let mut state = MissionControlState::default();
let batch = ActivityId::new("viewer-batch");
state.apply_activity_event(ActivityEvent::Started {
id: batch.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("subagents"),
});
let tasks = (0..task_count)
.map(|index| {
let task_id = batch.child(format!("g{}", index + 1));
state.apply_activity_event(ActivityEvent::Started {
id: task_id.clone(),
parent_id: Some(batch.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(format!("g{} task", index + 1)),
});
task_id
})
.collect::<Vec<_>>();
assert!(state.open_subagent_viewer(batch.clone(), tasks[0].clone()));
(state, batch, tasks)
}
fn add_diagnostic(
state: &mut MissionControlState,
parent: &ActivityId,
id: &str,
preview: String,
) {
let id = ActivityId::new(id);
state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: Some(parent.clone()),
kind: ActivityKind::Diagnostic,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(id.as_str()),
});
state.apply_activity_event(ActivityEvent::Delta { id, preview });
}
fn line_text(line: &TranscriptVisualLine) -> String {
line.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn synthetic_line(text: impl Into<String>) -> TranscriptVisualLine {
let text = text.into();
TranscriptVisualLine {
status_indicators: Vec::new(),
line: Line::from(Span::raw(text.clone())),
copy_text: text,
copy_byte_range: None,
copyable: true,
copy_continuation: false,
hit_regions: Vec::new(),
visual_copy_column_offset: 0,
visual_copy_byte_boundaries: Vec::new(),
}
}
#[test]
fn viewer_traversal_uses_parent_links_with_missing_and_stale_child_indexes() {
let (mut state, _, tasks) = viewer_state(2);
add_diagnostic(&mut state, &tasks[0], "child", "child preview".into());
let child = ActivityId::new("child");
add_diagnostic(&mut state, &child, "grandchild", "nested preview".into());
let grandchild = ActivityId::new("grandchild");
add_diagnostic(&mut state, &tasks[1], "other", "unrelated".into());
state.children.clear();
state.children.insert(
tasks[0].clone(),
vec![ActivityId::new("other"), child.clone(), child.clone()],
);
assert_eq!(
subagent_viewer_activity_ids_with_omitted(&state, &tasks[0]),
(vec![child.clone(), grandchild.clone()], 0)
);
state.nodes.get_mut(&tasks[0]).unwrap().parent_id = Some(grandchild);
assert_eq!(
subagent_viewer_activity_ids_with_omitted(&state, &tasks[0])
.0
.len(),
2
);
}
#[test]
fn viewer_retains_newest_descendants_even_below_omitted_ancestors() {
let (mut state, _, tasks) = viewer_state(1);
let mut parent = tasks[0].clone();
let mut descendants = Vec::new();
for index in 0..MAX_SUBAGENT_VIEWER_ACTIVITY_NODES + 8 {
let id = format!("nested-{index}");
add_diagnostic(&mut state, &parent, &id, "preview".into());
parent = ActivityId::new(id);
descendants.push(parent.clone());
}
state.children.clear();
let (ids, omitted) = subagent_viewer_activity_ids_with_omitted(&state, &tasks[0]);
assert_eq!(omitted, 8);
assert_eq!(ids, descendants[8..]);
}
#[test]
fn viewer_opens_requested_sibling_and_cycles_tabs_in_order() {
let (mut state, batch, tasks) = viewer_state(3);
assert_eq!(state.subagent_viewer_tabs(), tasks);
assert_eq!(
state.subagent_viewer_selected_task_id(),
Some(tasks[0].clone())
);
assert!(state.open_subagent_viewer(batch.clone(), tasks[1].clone()));
assert_eq!(
state.subagent_viewer_selected_task_id(),
Some(tasks[1].clone())
);
assert!(state.select_next_subagent_viewer_tab());
assert_eq!(
state.subagent_viewer_selected_task_id(),
Some(tasks[2].clone())
);
assert!(state.select_previous_subagent_viewer_tab());
assert_eq!(
state.subagent_viewer_selected_task_id(),
Some(tasks[1].clone())
);
assert!(!state.select_subagent_viewer_tab(99));
assert!(!state.open_subagent_viewer(ActivityId::new("different-batch"), tasks[0].clone(),));
assert_eq!(
state.subagent_viewer_selected_task_id(),
Some(tasks[1].clone())
);
}
#[test]
fn viewer_scroll_state_is_preserved_per_tab_and_follow_live_tracks_new_content() {
let (mut state, _batch, tasks) = viewer_state(2);
add_diagnostic(
&mut state,
&tasks[0],
"viewer-batch/g1/diagnostic",
(0..30)
.map(|index| format!("line {index}"))
.collect::<Vec<_>>()
.join("\n"),
);
let visible_rows = 3_u16;
let wrap_width = 80;
let initial_overflow = state
.cached_subagent_viewer_timeline(&tasks[0], wrap_width)
.rows
.saturating_sub(usize::from(visible_rows));
assert!(initial_overflow > 0);
assert_eq!(
state.subagent_viewer_scroll_offset(visible_rows, wrap_width),
initial_overflow
);
assert!(state.scroll_subagent_viewer_up(2, visible_rows, wrap_width));
let pinned_offset = state.subagent_viewer_scroll_offset(visible_rows, wrap_width);
assert!(pinned_offset < initial_overflow);
assert!(
!state
.modals
.subagent_viewer
.as_ref()
.expect("viewer")
.tab_states
.get(&tasks[0])
.expect("first tab")
.follow_live
);
assert!(state.select_subagent_viewer_tab(1));
assert!(state.select_subagent_viewer_tab(0));
assert_eq!(
state.subagent_viewer_scroll_offset(visible_rows, wrap_width),
pinned_offset
);
assert!(state.end_subagent_viewer(visible_rows, wrap_width));
add_diagnostic(
&mut state,
&tasks[0],
"viewer-batch/g1/latest",
(0..20)
.map(|index| format!("latest {index}"))
.collect::<Vec<_>>()
.join("\n"),
);
let final_overflow = state
.cached_subagent_viewer_timeline(&tasks[0], wrap_width)
.rows
.saturating_sub(usize::from(visible_rows));
assert_eq!(
state.subagent_viewer_scroll_offset(visible_rows, wrap_width),
final_overflow
);
}
#[test]
fn viewer_timeline_cache_refreshes_for_activity_width_and_theme_changes() {
let (mut state, _batch, tasks) = viewer_state(1);
add_diagnostic(
&mut state,
&tasks[0],
"viewer-batch/g1/diagnostic",
"cached first line".to_string(),
);
let first_text = {
let timeline = state.cached_subagent_viewer_timeline(&tasks[0], 80);
timeline.lines.iter().map(line_text).collect::<Vec<_>>()
};
let first_key = state
.subagent_viewer_timeline_cache
.borrow()
.key
.clone()
.expect("cache key");
let second_text = {
let timeline = state.cached_subagent_viewer_timeline(&tasks[0], 80);
timeline.lines.iter().map(line_text).collect::<Vec<_>>()
};
assert_eq!(first_text, second_text);
assert_eq!(
state.subagent_viewer_timeline_cache.borrow().key,
Some(first_key.clone())
);
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("viewer-batch/g1/diagnostic"),
preview: "cached updated line".to_string(),
});
let updated_text = {
let timeline = state.cached_subagent_viewer_timeline(&tasks[0], 80);
timeline.lines.iter().map(line_text).collect::<Vec<_>>()
};
assert!(
updated_text
.iter()
.any(|line| line.contains("cached updated line"))
);
let updated_key = state
.subagent_viewer_timeline_cache
.borrow()
.key
.clone()
.expect("updated cache key");
assert_ne!(updated_key.activity_revision, first_key.activity_revision);
let _ = state.cached_subagent_viewer_timeline(&tasks[0], 20);
assert_eq!(
state
.subagent_viewer_timeline_cache
.borrow()
.key
.as_ref()
.map(|key| key.width),
Some(20)
);
let themed = crate::tui::theme::MissionControlTheme::from_runtime_appearance(
&crate::appearance::RuntimeAppearance::default(),
1,
);
assert!(state.set_theme(themed, false));
let _ = state.cached_subagent_viewer_timeline(&tasks[0], 20);
assert_eq!(
state
.subagent_viewer_timeline_cache
.borrow()
.key
.as_ref()
.map(|key| key.theme_revision),
Some(1)
);
}
#[test]
fn viewer_reuses_selected_projection_during_unrelated_activity() {
let (mut state, _, tasks) = viewer_state(2);
add_diagnostic(
&mut state,
&tasks[0],
"selected-child",
"selected preview".into(),
);
let lines_ptr = state
.cached_subagent_viewer_timeline(&tasks[0], 40)
.lines
.as_ptr();
assert_eq!(state.subagent_viewer_tabs(), tasks);
let tabs_ptr = state.subagent_viewer_timeline_cache.borrow().tabs.as_ptr();
state.apply_activity_event(ActivityEvent::Delta {
id: tasks[1].clone(),
preview: "other task update".into(),
});
assert_eq!(state.subagent_viewer_tabs(), tasks);
assert_eq!(
state.subagent_viewer_timeline_cache.borrow().tabs.as_ptr(),
tabs_ptr
);
let timeline = state.cached_subagent_viewer_timeline(&tasks[0], 40);
assert_eq!(timeline.lines.as_ptr(), lines_ptr);
assert!(
timeline
.lines
.iter()
.any(|line| line_text(line).contains("selected preview"))
);
}
#[test]
fn viewer_reparenting_and_removal_drop_old_descendants() {
let (mut state, _, tasks) = viewer_state(2);
add_diagnostic(
&mut state,
&tasks[0],
"moving-child",
"moving preview".into(),
);
assert!(
state
.cached_subagent_viewer_timeline(&tasks[0], 80)
.lines
.iter()
.any(|line| line_text(line).contains("moving preview"))
);
add_diagnostic(
&mut state,
&tasks[1],
"moving-child",
"moved preview".into(),
);
assert!(
!state
.cached_subagent_viewer_timeline(&tasks[0], 80)
.lines
.iter()
.any(|line| line_text(line).contains("moving preview"))
);
assert!(
state
.cached_subagent_viewer_timeline(&tasks[1], 80)
.lines
.iter()
.any(|line| line_text(line).contains("moved preview"))
);
state.nodes.remove(&ActivityId::new("moving-child"));
assert!(
!state
.cached_subagent_viewer_timeline(&tasks[1], 80)
.lines
.iter()
.any(|line| line_text(line).contains("moved preview"))
);
}
#[test]
fn viewer_reuses_unchanged_cards_when_another_descendant_updates() {
let (mut state, _, tasks) = viewer_state(1);
add_diagnostic(
&mut state,
&tasks[0],
"stable-child",
"stable preview".into(),
);
add_diagnostic(&mut state, &tasks[0], "live-child", "first preview".into());
drop(state.cached_subagent_viewer_timeline(&tasks[0], 80));
let stable = ActivityId::new("stable-child");
let card_ptr = state.subagent_viewer_timeline_cache.borrow().cards[&stable]
.lines
.as_ptr();
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("live-child"),
preview: "new live preview".into(),
});
assert!(
state
.cached_subagent_viewer_timeline(&tasks[0], 80)
.lines
.iter()
.any(|line| line_text(line).contains("new live preview"))
);
assert_eq!(
state.subagent_viewer_timeline_cache.borrow().cards[&stable]
.lines
.as_ptr(),
card_ptr
);
}
#[test]
fn viewer_cached_tabs_follow_reparenting_and_missing_indexes() {
let (mut state, batch, tasks) = viewer_state(2);
state.children.clear();
state.invalidate_subagent_viewer_tabs();
assert_eq!(state.subagent_viewer_tabs(), tasks);
state.apply_activity_event(ActivityEvent::Started {
id: tasks[1].clone(),
parent_id: None,
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("moved task"),
});
assert_eq!(
state.subagent_viewer_tabs_for_batch(&batch),
vec![tasks[0].clone()]
);
}
#[test]
fn viewer_large_cached_card_preserves_visuals_and_exact_omissions() {
let (mut state, _batch, tasks) = viewer_state(1);
add_diagnostic(&mut state, &tasks[0], "large-card", "initial".into());
state
.nodes
.get_mut(&ActivityId::new("large-card"))
.unwrap()
.preview = (0..10_000)
.map(|index| format!("body line {index}"))
.collect::<Vec<_>>()
.join("\n");
let node = &state.nodes[&ActivityId::new("large-card")];
let full = crate::tui::transcript_cards::activity_card_visual_lines(&state, node, 80, true);
let block = cached_activity_card_lines(&state, node, 80, true);
assert_eq!(block.original_line_count, full.len());
assert_eq!(block.lines.len(), MAX_SUBAGENT_VIEWER_TIMELINE_LINES);
assert!(viewer_card_storage_bytes(&block.lines) <= MAX_SUBAGENT_VIEWER_CARD_BYTES);
for capacity in [0, 1, 2, 3, 4, 128, 254, 255, 256] {
let expected = retain_oversized_activity_block(full.clone(), capacity);
assert_eq!(retain_cached_activity_block(&block, capacity), expected);
}
let cached = cached_activity_card_lines(&state, node, 80, true);
assert!(Rc::ptr_eq(&block.lines, &cached.lines));
let root = vec![synthetic_line("selected header")];
let retained = retain_viewer_blocks(&state, 80, vec![root.into(), block], 0);
assert_eq!(retained.len(), MAX_SUBAGENT_VIEWER_TIMELINE_LINES);
assert_eq!(
line_text(&retained[1]).trim(),
viewer_omission_text(0, full.len() - 254)
);
assert!(
retained
.iter()
.any(|line| line_text(line).contains("body line 9999"))
);
}
#[test]
fn viewer_does_not_cache_cards_over_byte_budget() {
let (mut state, _batch, tasks) = viewer_state(1);
add_diagnostic(&mut state, &tasks[0], "wide-card", "wide body".into());
state
.nodes
.get_mut(&ActivityId::new("wide-card"))
.unwrap()
.preview = (0..200)
.map(|_| "x".repeat(4000))
.collect::<Vec<_>>()
.join("\n");
let node = &state.nodes[&ActivityId::new("wide-card")];
let block = cached_activity_card_lines(&state, node, u16::MAX, true);
assert!(viewer_card_storage_bytes(&block.lines) > MAX_SUBAGENT_VIEWER_CARD_BYTES);
assert!(
!state
.subagent_viewer_timeline_cache
.borrow()
.cards
.contains_key(&node.id)
);
let expected =
crate::tui::transcript_cards::activity_card_visual_lines(&state, node, u16::MAX, true);
assert_eq!(*block.lines, expected);
}
#[test]
fn viewer_retention_keeps_complete_newest_blocks_and_reports_exact_omissions() {
let state = MissionControlState::default();
let mut older = vec![synthetic_line("older header")];
older.extend((0..199).map(|index| synthetic_line(format!("older body {index}"))));
let mut newest = vec![synthetic_line("newest header")];
newest.extend((0..99).map(|index| synthetic_line(format!("newest body {index}"))));
let blocks = vec![
vec![synthetic_line("selected header")].into(),
older.into(),
newest.into(),
];
let retained = retain_viewer_blocks(&state, 80, blocks, 0);
assert!(retained.len() <= MAX_SUBAGENT_VIEWER_TIMELINE_LINES);
assert_eq!(retained.len(), 102);
assert_eq!(line_text(&retained[0]), "selected header");
assert_eq!(
line_text(&retained[1]).trim(),
"… 1 earlier activity and 200 lines omitted …"
);
let retained_text = retained
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(retained_text.contains("newest header"));
assert!(retained_text.contains("newest body 98"));
assert!(!retained_text.contains("older header"));
assert!(!retained_text.contains("older body 0"));
}
#[test]
fn viewer_retention_bounds_oversized_newest_and_omits_older_blocks_atomically() {
let state = MissionControlState::default();
let mut older = vec![synthetic_line("older header")];
older.extend((0..9).map(|index| synthetic_line(format!("older body {index}"))));
let mut newest = vec![synthetic_line("newest header")];
newest.extend((0..299).map(|index| synthetic_line(format!("newest body {index}"))));
let retained = retain_viewer_blocks(
&state,
80,
vec![
vec![synthetic_line("selected header")].into(),
older.into(),
newest.into(),
],
0,
);
assert_eq!(retained.len(), MAX_SUBAGENT_VIEWER_TIMELINE_LINES);
assert_eq!(line_text(&retained[0]), "selected header");
assert_eq!(
line_text(&retained[1]).trim(),
"… 1 earlier activity and 56 lines omitted …"
);
let retained_text = retained
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(retained_text.contains("newest header"));
assert!(retained_text.contains("newest body 298"));
assert!(!retained_text.contains("older header"));
assert!(!retained_text.contains("older body"));
}
#[test]
fn oversized_activity_card_bounding_preserves_header_newest_body_and_boundaries() {
let block = vec![
synthetic_line(""),
synthetic_line("descendant header"),
synthetic_line(""),
synthetic_line("old body"),
synthetic_line("newest body"),
synthetic_line(""),
];
let (bounded, omitted) = retain_oversized_activity_block(block.clone(), 4);
assert_eq!(omitted, 2);
assert_eq!(
bounded.iter().map(line_text).collect::<Vec<_>>(),
vec!["", "descendant header", "newest body", ""]
);
assert!(!bounded.iter().any(|line| line_text(line) == "old body"));
let (tiny, _) = retain_oversized_activity_block(block, 1);
assert_eq!(tiny.len(), 1);
assert_eq!(line_text(&tiny[0]), "descendant header");
assert!(!tiny.iter().any(|line| line_text(line) == "newest body"));
}
#[test]
fn viewer_timeline_bounds_real_activity_and_keeps_latest_node() {
let (mut state, _batch, tasks) = viewer_state(1);
for index in 0..5 {
add_diagnostic(
&mut state,
&tasks[0],
&format!("viewer-batch/g1/activity-{index}"),
(0..200)
.map(|line| format!("activity {index} line {line}"))
.collect::<Vec<_>>()
.join("\n"),
);
}
let lines = subagent_viewer_visual_lines(&state, &tasks[0], 80);
assert!(lines.len() <= MAX_SUBAGENT_VIEWER_TIMELINE_LINES);
let text = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("activity 4 line 199"), "{text}");
assert!(text.contains("earlier activities"), "{text}");
}
#[test]
fn viewer_visual_lines_sanitize_task_metadata_and_activity_output() {
let (mut state, _batch, tasks) = viewer_state(1);
let secret = "viewer-sanitizer-secret";
let task = tasks[0].clone();
state.nodes.get_mut(&task).expect("task").metadata = ActivityMetadata {
label: format!("authorization: Bearer {secret}\u{1b}[31m"),
detail: None,
fields: vec![(
"agent".to_string(),
format!("authorization: Bearer {secret}\u{1b}[32m"),
)],
};
add_diagnostic(
&mut state,
&task,
"viewer-batch/g1/output",
format!("visible viewer output\nauthorization: Bearer {secret}\u{1b}[2J"),
);
let text = subagent_viewer_visual_lines(&state, &task, 80)
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("visible viewer output"), "{text}");
assert!(text.contains("<redacted>"), "{text}");
assert!(!text.contains(secret), "{text}");
assert!(!text.contains('\u{1b}'), "{text}");
}
}