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) 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_line(&self) -> String {
let mut line = format!(
"tool: ⟳ {}{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)
}
}
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: ActivityId::new(format!("{}/{task_key}", parent_id.as_str())),
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.as_str().starts_with(&format!("{}/", task.id.as_str()))
})
}
pub(super) fn task_for_descendant(&self, id: &ActivityId) -> Option<&ActiveSubagentTask> {
self.tasks.iter().find(|task| {
id == &task.id || id.as_str().starts_with(&format!("{}/", task.id.as_str()))
})
}
pub(super) fn task_id_from_prefixed_descendant(
&self,
parent_id: &ActivityId,
id: &ActivityId,
) -> Option<ActivityId> {
let rest = id
.as_str()
.strip_prefix(&format!("{}/", parent_id.as_str()))?;
let task_key = rest.split('/').next().filter(|key| !key.is_empty())?;
Some(ActivityId::new(format!(
"{}/{task_key}",
parent_id.as_str()
)))
}
pub(super) fn ensure_task_from_descendant(&mut self, parent_id: &ActivityId, id: &ActivityId) {
let Some(rest) = id
.as_str()
.strip_prefix(&format!("{}/", parent_id.as_str()))
else {
return;
};
let Some(task_key) = rest.split('/').next().filter(|key| !key.is_empty()) else {
return;
};
let task_id = ActivityId::new(format!("{}/{task_key}", parent_id.as_str()));
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};
use std::collections::HashSet;
fn activity_id_has_path_ancestor(id: &ActivityId, ancestor: &ActivityId) -> bool {
id.as_str()
.strip_prefix(ancestor.as_str())
.is_some_and(|rest| rest.starts_with('/'))
}
impl MissionControlState {
pub(crate) fn apply_activity_event(&mut self, event: ActivityEvent) {
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.selected >= previous_visible_len.saturating_sub(1)
|| (self.activity_follow_tail
&& self.activity_scroll_offset() != usize::from(u16::MAX));
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::FinalPreview { id, .. }
| ActivityEvent::Finished { id, .. } => id.clone(),
};
match event {
ActivityEvent::Started {
id,
parent_id,
kind,
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);
let attached_parent_id =
parent_id.filter(|parent_id| self.nodes.contains_key(parent_id));
if new_node {
self.attach(&id, attached_parent_id.as_ref());
self.expanded.insert(id.clone());
} else {
let previous_parent_id =
self.nodes.get(&id).and_then(|node| node.parent_id.clone());
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(),
parent_id: attached_parent_id,
kind: kind.clone(),
status,
metadata,
started_at,
preview,
preview_truncated,
},
);
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);
}
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(),
parent_id: None,
kind: ActivityKind::Diagnostic,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(id.as_str()),
started_at: chrono::Local::now(),
preview: String::new(),
preview_truncated: false,
},
);
}
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::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(),
parent_id: None,
kind: ActivityKind::Diagnostic,
status,
metadata: metadata
.unwrap_or_else(|| ActivityMetadata::new(id.as_str())),
started_at: chrono::Local::now(),
preview: String::new(),
preview_truncated: false,
},
);
} else if let Some(node) = self.nodes.get_mut(&id) {
node.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());
}
}
if invalidate_visible_nodes {
self.invalidate_activity_visible_nodes_cache();
}
if invalidate_selected_detail {
self.invalidate_selected_activity_detail_cache();
}
self.invalidate_transcript_activity_caches_for_event(&activity_event_id);
self.follow_activity_tail_after_append(previous_visible_len, was_following_activity_tail);
}
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)
}
fn activity_is_descendant_of(&self, id: &ActivityId, ancestor: &ActivityId) -> bool {
let mut parent = self.nodes.get(id).and_then(|node| node.parent_id.clone());
while let Some(parent_id) = parent {
if &parent_id == ancestor {
return true;
}
parent = self
.nodes
.get(&parent_id)
.and_then(|node| node.parent_id.clone());
}
false
}
fn invalidate_transcript_activity_caches_for_event(&self, id: &ActivityId) {
if self.transcript_activity_links.is_empty() {
return;
}
let affected_entries = 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))
.flatten()
})
.collect::<Vec<_>>();
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)
|| activity_id_has_path_ancestor(event_id, linked_id)
|| activity_id_has_path_ancestor(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 <= previous_visible_len {
return;
}
let previous = self.selected;
self.selected = visible_len.saturating_sub(1);
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();
}
}
pub(super) fn attach(&mut self, id: &ActivityId, parent: Option<&ActivityId>) {
match parent.filter(|parent| self.nodes.contains_key(*parent)) {
Some(parent) => {
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());
}
}
}
}
fn detach(&mut self, id: &ActivityId, parent: Option<&ActivityId>) {
self.roots.retain(|root| root != id);
if let Some(parent) = parent
&& let Some(children) = self.children.get_mut(parent)
{
children.retain(|child| child != id);
}
}
fn aggregate_parent_status(&mut self, id: &ActivityId) {
let mut parent = self.nodes.get(id).and_then(|node| node.parent_id.clone());
while let Some(parent_id) = parent {
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_line()))
});
if let Some((absolute_index, line)) = rendered
&& let Some(index) = self.transcript_local_index(absolute_index)
&& let Some(transcript_line) = self.transcript.get_mut(index)
{
*transcript_line = line;
self.invalidate_transcript_entry_cache(index);
}
}
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.task_for_descendant(id) {
return Some(task.id.clone());
}
if let Some(task_id) = subagents.task_id_from_prefixed_descendant(parent_id, id) {
return Some(task_id);
}
let mut ancestor = self.nodes.get(id).and_then(|node| node.parent_id.clone());
let mut visited = HashSet::new();
while let Some(ancestor_id) = ancestor {
if !visited.insert(ancestor_id.clone()) {
break;
}
if let Some(task) = subagents.tasks.iter().find(|task| task.id == ancestor_id) {
return Some(task.id.clone());
}
if let Some(task) = subagents.task_for_descendant(&ancestor_id) {
return Some(task.id.clone());
}
if let Some(task_id) =
subagents.task_id_from_prefixed_descendant(parent_id, &ancestor_id)
{
return Some(task_id);
}
if ancestor_id == *parent_id {
break;
}
ancestor = self
.nodes
.get(&ancestor_id)
.and_then(|node| node.parent_id.clone());
}
None
}
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 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");
}
}