use super::transcript_state::{ContextUsageState, merge_context_usage, transcript_metadata_fields};
use crate::output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus, ContextUsageSource,
};
use crate::tui::{PADDED_INLINE_SEPARATOR, normalize_inline_separators, transcript};
use std::collections::VecDeque;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ActiveToolTranscript {
pub(super) index: usize,
pub(super) tool_name: String,
pub(super) label: String,
pub(super) activity: String,
pub(super) subagents: Option<ActiveSubagentTranscript>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ActiveSubagentTranscript {
pub(super) tasks: Vec<ActiveSubagentTask>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ActiveSubagentTask {
pub(super) id: ActivityId,
pub(super) label: String,
pub(super) status: ActivityStatus,
usage: Option<ContextUsageState>,
recent: VecDeque<String>,
}
impl ActiveToolTranscript {
pub(super) fn render_body(&self) -> String {
let mut line = format!("● {}{PADDED_INLINE_SEPARATOR}{}", self.label, self.activity);
if let Some(subagents) = &self.subagents {
for task in &subagents.tasks {
let branch = if subagents
.tasks
.last()
.is_some_and(|last| last.id == task.id)
{
"└─"
} else {
"├─"
};
let mut parts = vec![
task.label.clone(),
activity_status_text(task.status).to_string(),
];
if let Some(usage) = task.usage {
parts.push(compact_context_usage(usage));
}
line.push_str(&format!(
"\n {branch} {}",
parts.join(PADDED_INLINE_SEPARATOR)
));
for recent in &task.recent {
line.push_str(&format!("\n │ • {recent}"));
}
}
}
transcript::sanitize_preview(&line)
}
pub(super) fn render_entry(&self) -> transcript::TranscriptEntry {
transcript::TranscriptEntry::tool(
self.render_body(),
transcript::TranscriptToolData {
identity: transcript::TranscriptToolIdentity::Named {
name: self.tool_name.clone(),
status: Some(ActivityStatus::Running),
},
children: self
.subagents
.as_ref()
.map(|subagents| {
subagents
.tasks
.iter()
.map(|task| {
transcript::subagent_summary(&task.label, Some(task.status))
})
.collect()
})
.unwrap_or_default(),
},
)
}
}
impl ActiveSubagentTranscript {
pub(super) fn from_tool_call(parent_id: &ActivityId, arguments: &serde_json::Value) -> Self {
let tasks = arguments
.get("tasks")
.and_then(|value| value.as_array())
.map(|tasks| {
tasks
.iter()
.enumerate()
.map(|(index, task)| {
let task_key = format!("g{}", index + 1);
ActiveSubagentTask {
id: parent_id.child(&task_key),
label: task_label(&task_key, task),
status: ActivityStatus::Queued,
usage: None,
recent: VecDeque::new(),
}
})
.collect()
})
.unwrap_or_default();
Self { tasks }
}
pub(super) fn task_for_descendant_mut(
&mut self,
id: &ActivityId,
) -> Option<&mut ActiveSubagentTask> {
self.tasks
.iter_mut()
.find(|task| id == &task.id || id.is_path_descendant_of(&task.id))
}
pub(super) fn task_for_descendant(&self, id: &ActivityId) -> Option<&ActiveSubagentTask> {
self.tasks
.iter()
.find(|task| id == &task.id || id.is_path_descendant_of(&task.id))
}
pub(super) fn task_id_from_prefixed_descendant(
&self,
parent_id: &ActivityId,
id: &ActivityId,
) -> Option<ActivityId> {
let task_key = id
.path_suffix_after(parent_id)?
.split('/')
.next()
.filter(|key| !key.is_empty())?;
Some(parent_id.child(task_key))
}
pub(super) fn ensure_task_from_descendant(&mut self, parent_id: &ActivityId, id: &ActivityId) {
let Some(task_key) = id
.path_suffix_after(parent_id)
.and_then(|rest| rest.split('/').next().filter(|key| !key.is_empty()))
else {
return;
};
let task_id = parent_id.child(task_key);
if self.tasks.iter().any(|task| task.id == task_id) {
return;
}
self.tasks.push(ActiveSubagentTask {
id: task_id,
label: bounded_transcript_label(task_key, 80),
status: ActivityStatus::Queued,
usage: None,
recent: VecDeque::new(),
});
}
}
impl ActiveSubagentTask {
pub(super) fn push_recent(&mut self, summary: impl AsRef<str>) {
let summary = transcript::sanitize_preview(summary.as_ref());
if summary.trim().is_empty() {
return;
}
self.recent.push_back(summary);
while self.recent.len() > 3 {
self.recent.pop_front();
}
}
pub(super) fn update_usage(
&mut self,
current_tokens: usize,
max_tokens: usize,
reasoning_tokens: Option<usize>,
source: ContextUsageSource,
request_sequence: u64,
) {
let incoming = ContextUsageState {
current_tokens,
max_tokens,
reasoning_tokens,
source,
request_sequence,
};
self.usage = Some(merge_context_usage(self.usage, incoming));
}
}
fn compact_context_usage(usage: ContextUsageState) -> String {
let percent = usage
.current_tokens
.saturating_mul(100)
.checked_div(usage.max_tokens)
.map_or("--".to_string(), |percent| percent.to_string());
let mut parts = vec![format!(
"ctx {percent}%/{}",
compact_token_limit(usage.max_tokens)
)];
if let Some(reasoning_tokens) = usage.reasoning_tokens {
parts.push(format!("thinking {reasoning_tokens}"));
}
parts.join(PADDED_INLINE_SEPARATOR)
}
fn compact_token_limit(tokens: usize) -> String {
if tokens < 1_000 {
return tokens.to_string();
}
format!("{}k", tokens.saturating_add(500) / 1_000)
}
fn task_label(task_key: &str, task: &serde_json::Value) -> String {
let mut parts = vec![task_key.to_string()];
if let Some(identity) = task
.get("identity")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
{
parts.push(format!("identity {identity}"));
}
if let Some(agent) = task
.get("agent")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
{
parts.push(format!("agent {agent}"));
}
bounded_transcript_label(&parts.join(PADDED_INLINE_SEPARATOR), 80)
}
pub(super) fn bounded_transcript_label(value: &str, max_width: usize) -> String {
let sanitized = normalize_inline_separators(&transcript::sanitize_preview(value));
if UnicodeWidthStr::width(sanitized.as_str()) <= max_width {
return sanitized;
}
let ellipsis_width = UnicodeWidthStr::width("…");
let content_width = max_width.saturating_sub(ellipsis_width);
let mut truncated = String::new();
let mut width = 0usize;
for grapheme in sanitized.graphemes(true) {
let next_width = width.saturating_add(UnicodeWidthStr::width(grapheme));
if next_width > content_width {
break;
}
truncated.push_str(grapheme);
width = next_width;
}
truncated.push('…');
truncated
}
pub(super) fn activity_kind_text(kind: &ActivityKind) -> &'static str {
match kind {
ActivityKind::Tool => "tool",
ActivityKind::Hook => "hook",
ActivityKind::ProviderContextInjection => "provider context injection",
ActivityKind::SubagentBatch => "batch",
ActivityKind::SubagentTask => "subagent",
ActivityKind::Assistant => "assistant",
ActivityKind::Diagnostic => "diagnostic",
ActivityKind::Compaction => "compaction",
}
}
pub(super) fn activity_status_text(status: ActivityStatus) -> &'static str {
match status {
ActivityStatus::Queued => "queued",
ActivityStatus::Running => "running",
ActivityStatus::Writing => "writing",
ActivityStatus::Success => "completed",
ActivityStatus::Failed => "failed",
ActivityStatus::Canceled => "canceled",
}
}
fn is_active_status(status: ActivityStatus) -> bool {
matches!(
status,
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing
)
}
fn root_activity_text(status: ActivityStatus, metadata: &ActivityMetadata) -> String {
let mut parts = vec![activity_status_text(status).to_string()];
parts.extend(transcript_metadata_fields(&metadata.fields));
parts.join(PADDED_INLINE_SEPARATOR)
}
use super::MissionControlState;
use crate::tui::activity::{self, ActivityNode, ActivityParentProvenance};
use std::collections::HashSet;
impl MissionControlState {
pub(crate) fn apply_activity_event(&mut self, event: ActivityEvent) {
if let ActivityEvent::FastObservation {
provider_id,
model,
requested_service_tier,
outcome,
request_sequence,
run_order,
} = &event
{
self.accept_fast_observation(
provider_id,
model,
requested_service_tier,
outcome,
*request_sequence,
*run_order,
);
return;
}
if let ActivityEvent::ToolResultDetail { detail, .. } = &event {
self.session_files.record(
&detail.tool_name,
detail.status == ActivityStatus::Success,
&detail.metadata,
&[],
);
}
if let ActivityEvent::UsageSnapshot {
id,
usage,
request_sequence,
final_usage,
} = &event
&& id
.0
.starts_with(super::super::session_usage::USAGE_ACTIVITY_PREFIX)
{
self.session_usage
.observe(super::super::session_usage::SessionUsageRecord {
id: id.0.clone(),
usage: usage.whole_run,
request_sequence: *request_sequence,
final_usage: *final_usage,
});
self.refresh_session_cache_percent();
return;
}
let previous_visible_len = self.visible_node_count();
let previous_selected = self.selected_activity_id();
let was_following_activity_tail = previous_visible_len == 0 || self.activity_follow_tail;
let mut invalidate_visible_nodes = false;
let mut invalidate_selected_detail = false;
let activity_event_id = match &event {
ActivityEvent::Started { id, .. }
| ActivityEvent::Delta { id, .. }
| ActivityEvent::UsageUpdate { id, .. }
| ActivityEvent::UsageSnapshot { id, .. }
| ActivityEvent::ToolStartedDetail { id, .. }
| ActivityEvent::ToolResultDetail { id, .. }
| ActivityEvent::FinalPreview { id, .. }
| ActivityEvent::Finished { id, .. } => id.clone(),
ActivityEvent::FastObservation { .. } => {
unreachable!("Fast activity was handled above")
}
};
let previous_transcript_activity_entries = match &event {
ActivityEvent::Started { id, .. } => self.transcript_activity_entries_for_event(id),
_ => Vec::new(),
};
match event {
ActivityEvent::Started {
id,
parent_id,
kind,
status,
metadata,
} => {
self.invalidate_subagent_viewer_tabs();
let new_node = !self.nodes.contains_key(&id);
invalidate_visible_nodes |= new_node;
invalidate_selected_detail |=
self.activity_update_affects_selected_detail(&previous_selected, &id);
let requested_parent_id = parent_id
.as_ref()
.filter(|parent_id| self.nodes.contains_key(*parent_id))
.cloned();
let incoming_parent_provenance = match &parent_id {
Some(parent_id) => ActivityParentProvenance::Declared(parent_id.clone()),
None => ActivityParentProvenance::ExplicitRoot,
};
let (previous_parent_id, previous_parent_provenance) = self
.nodes
.get(&id)
.map(|node| (node.parent_id.clone(), node.parent_provenance.clone()))
.unwrap_or((None, ActivityParentProvenance::Unavailable));
let parent_assignment_rejected = requested_parent_id
.as_ref()
.is_some_and(|parent_id| self.parent_assignment_would_cycle(&id, parent_id));
let (attached_parent_id, parent_provenance) = if parent_assignment_rejected {
if new_node {
(None, incoming_parent_provenance)
} else {
(previous_parent_id.clone(), previous_parent_provenance)
}
} else {
(requested_parent_id, incoming_parent_provenance)
};
if new_node {
self.attach(&id, attached_parent_id.as_ref());
self.expanded.insert(id.clone());
} else if previous_parent_id != attached_parent_id {
self.detach(&id, previous_parent_id.as_ref());
self.attach(&id, attached_parent_id.as_ref());
invalidate_visible_nodes = true;
}
let (preview, preview_truncated) = self
.nodes
.get(&id)
.map(|n| (n.preview.clone(), n.preview_truncated))
.unwrap_or_default();
let started_at = self
.nodes
.get(&id)
.map(|node| node.started_at)
.unwrap_or_else(chrono::Local::now);
let parent_for_transcript = attached_parent_id.clone();
let metadata_label = metadata.label.clone();
let root_activity = parent_for_transcript
.is_none()
.then(|| root_activity_text(status, &metadata));
self.nodes.insert(
id.clone(),
ActivityNode {
id: id.clone(),
transcript_tool_name: None,
transcript_subagent: None,
transcript_reasoning: None,
transcript_bash_command: None,
parent_id: attached_parent_id,
parent_provenance,
kind: kind.clone(),
status,
metadata,
started_at,
update_sequence: 0,
own_update_sequence: 0,
preview,
preview_truncated,
tool_detail: self.nodes.get(&id).and_then(|n| n.tool_detail.clone()),
hash_edit_tree_summary: if matches!(&kind, ActivityKind::Tool) {
self.nodes
.get(&id)
.and_then(|n| n.hash_edit_tree_summary.clone())
} else {
None
},
usage: self.nodes.get(&id).and_then(|n| n.usage),
last_known_cache_percent: self
.nodes
.get(&id)
.and_then(|n| n.last_known_cache_percent),
last_known_latest_cache_percent: self
.nodes
.get(&id)
.and_then(|n| n.last_known_latest_cache_percent),
},
);
invalidate_visible_nodes |= self.reattach_waiting_children(&id);
if let Some(parent_id) = parent_for_transcript.as_ref() {
self.update_tool_transcript(parent_id, &format!("current: {metadata_label}"));
} else if let Some(root_activity) = root_activity {
self.update_tool_transcript(&id, &root_activity);
}
if new_node {
self.update_subagent_transcript_started(&id, &kind, status, &metadata_label);
}
}
ActivityEvent::Delta { id, preview } => {
let new_node = !self.nodes.contains_key(&id);
invalidate_visible_nodes |= new_node;
invalidate_selected_detail |=
self.activity_update_affects_selected_detail(&previous_selected, &id);
if new_node {
self.attach(&id, None);
self.nodes.insert(
id.clone(),
ActivityNode {
id: id.clone(),
transcript_tool_name: None,
transcript_subagent: None,
transcript_reasoning: None,
transcript_bash_command: None,
parent_id: None,
parent_provenance: ActivityParentProvenance::Unavailable,
kind: ActivityKind::Diagnostic,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(id.as_str()),
started_at: chrono::Local::now(),
update_sequence: 0,
own_update_sequence: 0,
preview: String::new(),
preview_truncated: false,
tool_detail: None,
hash_edit_tree_summary: None,
usage: None,
last_known_cache_percent: None,
last_known_latest_cache_percent: None,
},
);
}
if let Some(node) = self.nodes.get_mut(&id) {
activity::append_preview(node, &preview);
}
}
ActivityEvent::UsageUpdate {
id,
current_tokens,
max_tokens,
reasoning_tokens,
source,
request_sequence,
} => {
self.update_subagent_transcript_usage(
&id,
current_tokens,
max_tokens,
reasoning_tokens,
source,
request_sequence,
);
}
ActivityEvent::UsageSnapshot { id, usage, .. } => {
if let Some(node) = self.nodes.get_mut(&id) {
if let Some(percent) = activity::cache_read_percent(usage.whole_run) {
node.last_known_cache_percent = Some(percent);
}
if let Some(percent) = usage.latest.and_then(activity::cache_read_percent) {
node.last_known_latest_cache_percent = Some(percent);
}
node.usage = Some(usage);
}
}
ActivityEvent::ToolStartedDetail { id, detail }
| ActivityEvent::ToolResultDetail { id, detail } => {
let new_node = !self.nodes.contains_key(&id);
invalidate_visible_nodes |= new_node;
invalidate_selected_detail |=
self.activity_update_affects_selected_detail(&previous_selected, &id);
if new_node {
self.attach(&id, None);
let hash_edit_tree_summary = activity::hash_edit_activity_tree_summary(&detail);
self.nodes.insert(
id.clone(),
ActivityNode {
id: id.clone(),
transcript_tool_name: None,
transcript_subagent: None,
transcript_reasoning: None,
transcript_bash_command: None,
parent_id: None,
parent_provenance: ActivityParentProvenance::Unavailable,
kind: ActivityKind::Tool,
status: detail.status,
metadata: ActivityMetadata::new(detail.label.as_ref()),
started_at: chrono::Local::now(),
update_sequence: 0,
own_update_sequence: 0,
preview: String::new(),
preview_truncated: false,
tool_detail: Some(detail),
hash_edit_tree_summary,
usage: None,
last_known_cache_percent: None,
last_known_latest_cache_percent: None,
},
);
} else if let Some(node) = self.nodes.get_mut(&id)
&& node.status != ActivityStatus::Canceled
{
if node.kind == ActivityKind::Tool && node.hash_edit_tree_summary.is_none() {
node.hash_edit_tree_summary =
activity::hash_edit_activity_tree_summary(&detail);
}
node.status = detail.status;
node.tool_detail = Some(detail);
}
}
ActivityEvent::FinalPreview {
id,
preview,
metadata,
status,
} => {
let new_node = !self.nodes.contains_key(&id);
invalidate_visible_nodes |= new_node;
invalidate_selected_detail |=
self.activity_update_affects_selected_detail(&previous_selected, &id);
let metadata = metadata.unwrap_or_else(|| {
self.nodes
.get(&id)
.map(|node| node.metadata.clone())
.unwrap_or_else(|| ActivityMetadata::new(id.as_str()))
});
let status = status.unwrap_or_else(|| {
self.nodes
.get(&id)
.map(|node| node.status)
.unwrap_or(ActivityStatus::Success)
});
self.upsert_activity_final_preview(id.clone(), &preview, metadata.clone(), status);
self.aggregate_parent_status(&id);
self.update_subagent_transcript_final_preview(&id, &metadata, status);
}
ActivityEvent::Finished {
id,
status,
metadata,
} => {
let new_node = !self.nodes.contains_key(&id);
invalidate_visible_nodes |= new_node;
invalidate_selected_detail |=
self.activity_update_affects_selected_detail(&previous_selected, &id);
if new_node {
self.attach(&id, None);
self.nodes.insert(
id.clone(),
ActivityNode {
id: id.clone(),
transcript_tool_name: None,
transcript_subagent: None,
transcript_reasoning: None,
transcript_bash_command: None,
parent_id: None,
parent_provenance: ActivityParentProvenance::Unavailable,
kind: ActivityKind::Diagnostic,
status,
metadata: metadata
.unwrap_or_else(|| ActivityMetadata::new(id.as_str())),
started_at: chrono::Local::now(),
update_sequence: 0,
own_update_sequence: 0,
preview: String::new(),
preview_truncated: false,
usage: None,
last_known_cache_percent: None,
last_known_latest_cache_percent: None,
tool_detail: None,
hash_edit_tree_summary: None,
},
);
} else if let Some(node) = self.nodes.get_mut(&id)
&& node.status != ActivityStatus::Canceled
{
node.status = status;
if let Some(detail) = node.tool_detail.as_mut() {
detail.status = status;
}
if let Some(metadata) = metadata {
node.metadata = metadata;
}
}
self.aggregate_parent_status(&id);
if let Some(parent_id) = self.nodes.get(&id).and_then(|node| node.parent_id.clone())
{
let progress = self.activity_child_progress(&parent_id);
self.update_tool_transcript(&parent_id, &progress);
} else if let Some(metadata) = self.nodes.get(&id).map(|node| node.metadata.clone())
{
self.update_tool_transcript(&id, &root_activity_text(status, &metadata));
} else {
self.update_tool_transcript(&id, activity_status_text(status));
}
let metadata = self.nodes.get(&id).map(|node| node.metadata.clone());
self.update_subagent_transcript_finished(&id, status, metadata.as_ref());
}
ActivityEvent::FastObservation { .. } => {
unreachable!("Fast activity was handled above")
}
}
if let Some(node) = self.nodes.get_mut(&activity_event_id) {
node.refresh_transcript_data();
}
self.mark_activity_updated(&activity_event_id);
invalidate_selected_detail |=
self.activity_update_affects_selected_detail(&previous_selected, &activity_event_id);
if invalidate_visible_nodes {
self.invalidate_activity_visible_nodes_cache();
self.normalize_selection_after_visible_change(previous_selected.clone());
}
if invalidate_selected_detail {
self.invalidate_selected_activity_detail_cache();
}
self.invalidate_transcript_activity_caches_for_event(
&activity_event_id,
previous_transcript_activity_entries,
);
self.follow_activity_tail_after_append(previous_visible_len, was_following_activity_tail);
}
fn mark_activity_updated(&mut self, id: &ActivityId) {
self.activity_update_sequence = self.activity_update_sequence.saturating_add(1);
let sequence = self.activity_update_sequence;
if let Some(node) = self.nodes.get_mut(id) {
node.own_update_sequence = sequence;
}
let mut affected = HashSet::from([id.clone()]);
let mut ancestor = self.nodes.get(id).and_then(|node| node.parent_id.clone());
while let Some(parent_id) = ancestor {
if !affected.insert(parent_id.clone()) {
break;
}
ancestor = self
.nodes
.get(&parent_id)
.and_then(|node| node.parent_id.clone());
}
let use_path_fallback = self.nodes.get(id).is_none_or(|node| {
node.parent_id.is_none()
&& matches!(
node.parent_provenance,
ActivityParentProvenance::Unavailable
)
});
if use_path_fallback {
for (index, _) in id.as_str().match_indices('/') {
let ancestor = ActivityId::new(&id.as_str()[..index]);
if self
.nodes
.get(&ancestor)
.is_some_and(|node| node.kind == ActivityKind::SubagentTask)
{
affected.insert(ancestor);
}
}
}
for affected_id in affected {
if let Some(node) = self.nodes.get_mut(&affected_id) {
node.update_sequence = sequence;
}
}
}
fn activity_update_affects_selected_detail(
&self,
selected: &Option<ActivityId>,
id: &ActivityId,
) -> bool {
let Some(selected) = selected else {
return false;
};
selected == id || self.activity_is_descendant_of(id, selected)
}
pub(crate) fn activity_is_descendant_of(&self, id: &ActivityId, ancestor: &ActivityId) -> bool {
if id == ancestor {
return false;
}
let Some(_) = self.nodes.get(id) else {
return id.is_path_descendant_of(ancestor);
};
let mut current = id;
let mut visited = HashSet::new();
let mut has_visual_parent = false;
loop {
if !visited.insert(current.clone()) {
return false;
}
let Some(node) = self.nodes.get(current) else {
return false;
};
let Some(parent_id) = node.parent_id.as_ref() else {
return !has_visual_parent
&& matches!(
&node.parent_provenance,
ActivityParentProvenance::Unavailable
)
&& id.is_path_descendant_of(ancestor);
};
has_visual_parent = true;
if parent_id == ancestor {
return true;
}
if !self.nodes.contains_key(parent_id) {
return false;
}
current = parent_id;
}
}
fn transcript_activity_entries_for_event(&self, id: &ActivityId) -> Vec<usize> {
if self.transcript.activity_links().is_empty() {
return Vec::new();
}
self.transcript
.activity_links()
.iter()
.filter_map(|(index, link)| {
self.activity_event_affects_transcript_link(id, &link.activity_id)
.then(|| self.transcript_local_index(index.index()))
.flatten()
})
.collect()
}
fn invalidate_transcript_activity_caches_for_event(
&self,
id: &ActivityId,
previous_entries: Vec<usize>,
) {
let current_entries = self.transcript_activity_entries_for_event(id);
if previous_entries.is_empty() {
self.invalidate_transcript_entries_cache(current_entries);
return;
}
let mut affected_entries = previous_entries;
affected_entries.extend(current_entries);
affected_entries.sort_unstable();
affected_entries.dedup();
self.invalidate_transcript_entries_cache(affected_entries);
}
fn activity_event_affects_transcript_link(
&self,
event_id: &ActivityId,
linked_id: &ActivityId,
) -> bool {
event_id == linked_id
|| self.activity_is_descendant_of(event_id, linked_id)
|| self.activity_is_descendant_of(linked_id, event_id)
}
fn follow_activity_tail_after_append(
&mut self,
previous_visible_len: usize,
was_following_activity_tail: bool,
) {
if !was_following_activity_tail {
return;
}
let visible_len = self.visible_node_count();
if visible_len == 0 {
self.selected = 0;
self.set_scroll_offset(&self.scroll_views.activity_tree, 0);
return;
}
let tail = visible_len.saturating_sub(1);
if visible_len <= previous_visible_len && self.selected == tail {
return;
}
let previous = self.selected;
self.selected = tail;
if previous_visible_len > 0 {
self.set_scroll_offset(&self.scroll_views.activity_tree, usize::MAX);
}
self.activity_follow_tail = true;
if self.selected != previous {
self.reset_detail();
}
}
fn parent_assignment_would_cycle(&self, id: &ActivityId, parent: &ActivityId) -> bool {
let mut current = parent;
let mut visited = HashSet::new();
loop {
if current == id || !visited.insert(current.clone()) {
return true;
}
let Some(node) = self.nodes.get(current) else {
return false;
};
let Some(next) = node.parent_id.as_ref() else {
return false;
};
current = next;
}
}
fn reattach_waiting_children(&mut self, parent_id: &ActivityId) -> bool {
let waiting_children = self
.nodes
.iter()
.filter(|(_, node)| {
node.parent_id.is_none()
&& matches!(
&node.parent_provenance,
ActivityParentProvenance::Declared(declared_parent)
if declared_parent == parent_id
)
})
.map(|(child_id, _)| child_id.clone())
.collect::<Vec<_>>();
let mut changed = false;
for child_id in waiting_children {
if self.parent_assignment_would_cycle(&child_id, parent_id) {
continue;
}
self.attach(&child_id, Some(parent_id));
changed |= self
.nodes
.get(&child_id)
.is_some_and(|node| node.parent_id.as_ref() == Some(parent_id));
}
changed
}
pub(super) fn attach(&mut self, id: &ActivityId, parent: Option<&ActivityId>) {
let parent = parent
.filter(|parent| self.nodes.contains_key(*parent))
.cloned();
if let Some(parent) = parent.as_ref()
&& self.parent_assignment_would_cycle(id, parent)
{
return;
}
match parent.as_ref() {
Some(parent) => {
self.roots.retain(|root| root != id);
let children = self.children.entry(parent.clone()).or_default();
if !children.contains(id) {
children.push(id.clone());
}
}
None => {
if !self.roots.contains(id) {
self.roots.push(id.clone());
}
}
}
if let Some(node) = self.nodes.get_mut(id) {
node.parent_id = parent;
}
}
fn detach(&mut self, id: &ActivityId, parent: Option<&ActivityId>) {
self.mark_activity_updated(id);
self.roots.retain(|root| root != id);
if let Some(parent) = parent
&& let Some(children) = self.children.get_mut(parent)
{
children.retain(|child| child != id);
}
if let Some(node) = self.nodes.get_mut(id) {
node.parent_id = None;
}
}
fn aggregate_parent_status(&mut self, id: &ActivityId) {
let mut parent = self.nodes.get(id).and_then(|node| node.parent_id.clone());
let mut visited = HashSet::new();
while let Some(parent_id) = parent {
if !visited.insert(parent_id.clone()) {
break;
}
let statuses = self.children.get(&parent_id).cloned().unwrap_or_default();
let failed = statuses.iter().any(|child| {
self.nodes.get(child).is_some_and(|node| {
!matches!(
node.kind,
ActivityKind::Hook | ActivityKind::ProviderContextInjection
) && node.status == ActivityStatus::Failed
})
});
if failed
&& let Some(node) = self.nodes.get_mut(&parent_id)
&& !is_active_status(node.status)
{
node.status = ActivityStatus::Failed;
}
parent = self
.nodes
.get(&parent_id)
.and_then(|node| node.parent_id.clone());
}
}
fn update_subagent_transcript_started(
&mut self,
id: &ActivityId,
kind: &ActivityKind,
status: ActivityStatus,
label: &str,
) {
self.update_subagent_transcript(id, |task, is_task| {
if is_task {
task.status = status;
if matches!(kind, ActivityKind::SubagentTask) {
task.push_recent(format!("started {}", bounded_transcript_label(label, 80)));
}
} else {
task.push_recent(format!(
"{}: {}",
activity_kind_text(kind),
bounded_transcript_label(label, 80)
));
}
});
}
fn update_subagent_transcript_final_preview(
&mut self,
id: &ActivityId,
metadata: &ActivityMetadata,
status: ActivityStatus,
) {
self.update_subagent_transcript(id, |task, _| {
let summary = metadata
.detail
.as_deref()
.unwrap_or(metadata.label.as_str())
.trim();
if summary.is_empty() {
task.push_recent(format!("final: {}", activity_status_text(status)));
} else {
task.push_recent(format!("final: {}", bounded_transcript_label(summary, 100)));
}
});
}
fn update_subagent_transcript_finished(
&mut self,
id: &ActivityId,
status: ActivityStatus,
metadata: Option<&ActivityMetadata>,
) {
self.update_subagent_transcript(id, |task, is_task| {
if is_task {
task.status = status;
}
let label = metadata
.map(|metadata| metadata.label.as_str())
.unwrap_or_default()
.trim();
if label.is_empty() || is_task {
task.push_recent(format!("finished: {}", activity_status_text(status)));
} else {
task.push_recent(format!(
"finished {}: {}",
activity_status_text(status),
bounded_transcript_label(label, 80)
));
}
});
}
fn update_subagent_transcript_usage(
&mut self,
id: &ActivityId,
current_tokens: usize,
max_tokens: usize,
reasoning_tokens: Option<usize>,
source: ContextUsageSource,
request_sequence: u64,
) {
self.update_subagent_transcript(id, |task, _| {
task.update_usage(
current_tokens,
max_tokens,
reasoning_tokens,
source,
request_sequence,
);
});
}
fn update_subagent_transcript(
&mut self,
id: &ActivityId,
update: impl FnOnce(&mut ActiveSubagentTask, bool),
) {
self.retain_valid_active_tool_transcripts();
let mut update = Some(update);
let parent_ids = self
.active_tool_transcripts
.keys()
.cloned()
.collect::<Vec<_>>();
let rendered = parent_ids.into_iter().find_map(|parent_id| {
let task_id = {
let active = self.active_tool_transcripts.get(&parent_id)?;
let subagents = active.subagents.as_ref()?;
self.resolve_subagent_transcript_task(&parent_id, subagents, id)?
};
let active = self.active_tool_transcripts.get_mut(&parent_id)?;
let subagents = active.subagents.as_mut()?;
subagents.ensure_task_from_descendant(&parent_id, &task_id);
let task = subagents.task_for_descendant_mut(&task_id)?;
let is_task = task.id == *id;
let update = update.take()?;
update(task, is_task);
Some((active.index, active.render_entry()))
});
if let Some((absolute_index, entry)) = rendered
&& let Some(index) = self.transcript_local_index(absolute_index)
&& let Some(transcript_entry) = self.transcript.get_mut(index)
{
*transcript_entry = entry;
self.invalidate_transcript_entry_cache(index);
}
}
fn has_complete_activity_parent_graph(&self, id: &ActivityId) -> bool {
let Some(_) = self.nodes.get(id) else {
return false;
};
let mut current = id;
let mut visited = HashSet::new();
loop {
if !visited.insert(current.clone()) {
return true;
}
let Some(node) = self.nodes.get(current) else {
return false;
};
match &node.parent_provenance {
ActivityParentProvenance::Unavailable => return false,
ActivityParentProvenance::ExplicitRoot => return true,
ActivityParentProvenance::Declared(declared_parent) => {
if node.parent_id.as_ref() != Some(declared_parent)
|| !self.nodes.contains_key(declared_parent)
{
return false;
}
current = declared_parent;
}
}
}
}
fn activity_path_fallback_allowed(&self, id: &ActivityId) -> bool {
self.nodes.get(id).is_none_or(|node| {
matches!(
&node.parent_provenance,
ActivityParentProvenance::Unavailable
)
})
}
fn resolve_subagent_transcript_task(
&self,
parent_id: &ActivityId,
subagents: &ActiveSubagentTranscript,
id: &ActivityId,
) -> Option<ActivityId> {
if let Some(task) = subagents.tasks.iter().find(|task| task.id == *id) {
return Some(task.id.clone());
}
if let Some(task) = subagents
.tasks
.iter()
.find(|task| self.activity_is_descendant_of(id, &task.id))
{
return Some(task.id.clone());
}
if self.has_complete_activity_parent_graph(id) || !self.activity_path_fallback_allowed(id) {
return None;
}
if let Some(task) = subagents.task_for_descendant(id) {
return Some(task.id.clone());
}
subagents.task_id_from_prefixed_descendant(parent_id, id)
}
fn activity_child_progress(&self, parent_id: &ActivityId) -> String {
let children = self.children.get(parent_id).cloned().unwrap_or_default();
if children.is_empty() {
return "running".to_string();
}
let total = children.len();
let completed = children
.iter()
.filter(|id| {
self.nodes.get(id).is_some_and(|node| {
matches!(
node.status,
ActivityStatus::Success | ActivityStatus::Failed | ActivityStatus::Canceled
)
})
})
.count();
let failed = children
.iter()
.filter(|id| self.nodes.get(id).map(|node| node.status) == Some(ActivityStatus::Failed))
.count();
let running = children.iter().find_map(|id| {
let node = self.nodes.get(id)?;
matches!(
node.status,
ActivityStatus::Running | ActivityStatus::Writing
)
.then(|| node.metadata.label.clone())
});
match running {
Some(label) => format!(
"{completed}/{total} complete{PADDED_INLINE_SEPARATOR}current: {}",
normalize_inline_separators(&label)
),
None if failed > 0 => {
format!("{completed}/{total} complete{PADDED_INLINE_SEPARATOR}{failed} failed")
}
None => format!("{completed}/{total} complete"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn update_sequences_follow_visual_parents_and_only_unavailable_path_fallback() {
let mut state = MissionControlState::default();
let path_parent = ActivityId::new("batch/g1");
let graph_parent = ActivityId::new("other");
for id in [&path_parent, &graph_parent] {
state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("task"),
});
}
let child = path_parent.child("tool");
state.apply_activity_event(ActivityEvent::Delta {
id: child.clone(),
preview: "early preview".into(),
});
assert_eq!(
state.nodes[&path_parent].update_sequence,
state.nodes[&child].own_update_sequence
);
let path_sequence = state.nodes[&path_parent].update_sequence;
for parent_id in [
None,
Some(ActivityId::new("missing")),
Some(graph_parent.clone()),
] {
state.apply_activity_event(ActivityEvent::Started {
id: child.clone(),
parent_id,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("tool"),
});
state.apply_activity_event(ActivityEvent::Delta {
id: child.clone(),
preview: "new preview".into(),
});
assert_eq!(state.nodes[&path_parent].update_sequence, path_sequence);
}
assert_eq!(
state.nodes[&graph_parent].update_sequence,
state.nodes[&child].own_update_sequence
);
}
#[test]
fn bounded_transcript_label_truncates_by_display_width() {
let label = bounded_transcript_label("界界e\u{0301}abcdef", 10);
assert!(UnicodeWidthStr::width(label.as_str()) <= 10, "{label}");
assert!(label.ends_with('…'), "{label}");
assert!(!label.contains('\u{fffd}'), "{label}");
}
#[test]
fn bounded_transcript_label_keeps_ascii_truncation_shape() {
assert_eq!(bounded_transcript_label("abcdef", 3), "ab…");
assert_eq!(bounded_transcript_label("abc", 3), "abc");
}
#[test]
fn explicit_activity_parent_graph_wins_over_conflicting_id_path() {
let mut state = MissionControlState::default();
let path_parent = ActivityId::new("batch");
let graph_parent = ActivityId::new("other");
let child = path_parent.child("g1");
for (id, parent_id) in [
(path_parent.clone(), None),
(graph_parent.clone(), None),
(child.clone(), Some(graph_parent.clone())),
] {
state.apply_activity_event(ActivityEvent::Started {
id,
parent_id,
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("activity"),
});
}
assert!(state.activity_is_descendant_of(&child, &graph_parent));
assert!(!state.activity_is_descendant_of(&child, &path_parent));
assert!(!state.activity_event_affects_transcript_link(&child, &path_parent));
}
#[test]
fn explicit_activity_root_does_not_inherit_path_ancestry() {
let mut state = MissionControlState::default();
let path_parent = ActivityId::new("batch");
let explicit_root = path_parent.child("g1");
state.apply_activity_event(ActivityEvent::Started {
id: path_parent.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("batch"),
});
state.apply_activity_event(ActivityEvent::Started {
id: explicit_root.clone(),
parent_id: None,
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("root task"),
});
assert_eq!(state.nodes[&explicit_root].parent_id, None);
assert_eq!(
&state.nodes[&explicit_root].parent_provenance,
&ActivityParentProvenance::ExplicitRoot
);
assert!(!state.activity_is_descendant_of(&explicit_root, &path_parent));
}
#[test]
fn declared_activity_parent_reattaches_when_parent_arrives() {
let mut state = MissionControlState::default();
let parent = ActivityId::new("batch");
let child = parent.child("g1");
state.apply_activity_event(ActivityEvent::Started {
id: child.clone(),
parent_id: Some(parent.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("child"),
});
assert_eq!(state.roots, vec![child.clone()]);
assert_eq!(state.nodes[&child].parent_id, None);
assert_eq!(
&state.nodes[&child].parent_provenance,
&ActivityParentProvenance::Declared(parent.clone())
);
assert!(!state.activity_is_descendant_of(&child, &parent));
assert!(!state.activity_event_affects_transcript_link(&child, &parent));
state.apply_activity_event(ActivityEvent::Started {
id: parent.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("parent"),
});
assert_eq!(state.roots, vec![parent.clone()]);
assert_eq!(state.children.get(&parent), Some(&vec![child.clone()]));
assert_eq!(state.nodes[&child].parent_id, Some(parent.clone()));
assert!(state.activity_is_descendant_of(&child, &parent));
assert!(state.activity_event_affects_transcript_link(&child, &parent));
assert_eq!(state.visible_nodes(), vec![(0, parent), (1, child)]);
}
#[test]
fn cyclic_activity_parent_assignment_preserves_existing_tree() {
let mut state = MissionControlState::default();
let root = ActivityId::new("cycle/root");
let child = root.child("child");
let path_root = ActivityId::new("cycle");
state.apply_activity_event(ActivityEvent::Started {
id: root.clone(),
parent_id: None,
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("root"),
});
state.apply_activity_event(ActivityEvent::Started {
id: child.clone(),
parent_id: Some(root.clone()),
kind: ActivityKind::Assistant,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("child"),
});
state.apply_activity_event(ActivityEvent::Started {
id: root.clone(),
parent_id: Some(child.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("root"),
});
assert_eq!(state.roots, vec![root.clone()]);
assert_eq!(state.children.get(&root), Some(&vec![child.clone()]));
assert_eq!(state.nodes[&root].parent_id, None);
assert_eq!(
&state.nodes[&root].parent_provenance,
&ActivityParentProvenance::ExplicitRoot
);
assert!(!state.activity_is_descendant_of(&child, &path_root));
assert_eq!(state.visible_nodes(), vec![(0, root), (1, child)]);
}
}