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, Default)]
pub(crate) struct SubagentPromptDraft {
pub(crate) editor: super::PromptEditor,
pub(crate) autocomplete: Option<super::AutocompleteState>,
pub(crate) dismissed_token: Option<String>,
pub(crate) notice: String,
}
#[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(crate) fn selected_subagent_control(
&self,
) -> Option<crate::subagents::control::SubagentControl> {
let id = self.subagent_viewer_selected_task_id()?;
if self.subagent_control_namespace.is_empty() {
return None;
}
let raw = id.as_str().strip_prefix(&self.subagent_control_namespace)?;
self.subagent_controls.get(&ActivityId::new(raw))
}
pub(crate) fn subagent_prompt_editable(&self) -> bool {
self.selected_subagent_control()
.is_some_and(|control| control.is_active())
}
pub(crate) fn selected_subagent_prompt(&self) -> Option<&SubagentPromptDraft> {
self.subagent_prompt_drafts
.get(&self.subagent_viewer_selected_task_id()?)
}
pub(crate) fn set_subagent_prompt_notice(&mut self, notice: String) {
if let Some(id) = self.subagent_viewer_selected_task_id() {
self.subagent_prompt_drafts.entry(id).or_default().notice = notice;
}
}
pub(crate) fn with_selected_subagent_prompt<R>(
&mut self,
edit: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let id = self.subagent_viewer_selected_task_id()?;
let mut draft = self.subagent_prompt_drafts.remove(&id).unwrap_or_default();
std::mem::swap(&mut self.prompt_editor, &mut draft.editor);
std::mem::swap(&mut self.autocomplete, &mut draft.autocomplete);
std::mem::swap(
&mut self.autocomplete_dismissed_token,
&mut draft.dismissed_token,
);
let focus = self.focus_pane;
self.focus_pane = super::TuiFocusPane::Prompt;
let previous_status = self.status.clone();
let result = edit(self);
if self.status != previous_status {
draft.notice = self.status.clone();
}
self.focus_pane = focus;
std::mem::swap(&mut self.prompt_editor, &mut draft.editor);
std::mem::swap(&mut self.autocomplete, &mut draft.autocomplete);
std::mem::swap(
&mut self.autocomplete_dismissed_token,
&mut draft.dismissed_token,
);
self.subagent_prompt_drafts.insert(id, draft);
Some(result)
}
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 {
self.subagent_prompt_drafts.entry(tab.clone()).or_default();
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
}