mod read_metadata;
use read_metadata::compact_read_activity_detail;
use read_metadata::shortest_unique_activity_path_suffix;
pub(crate) use read_metadata::sanitize_read_activity_label;
use read_metadata::sanitize_read_activity_target;
use super::transcript::sanitize_preview;
use crate::{
output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus,
is_credential_like_key, redact_sensitive_text,
},
rendering::{DisplayLine, DisplayRole, DisplaySpan, render},
tui::{PADDED_INLINE_SEPARATOR, normalize_inline_separators},
};
use serde_json::Value;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub(crate) const MAX_ACTIVITY_PREVIEW_BYTES: usize = 16 * 1024;
pub(crate) const MAX_ACTIVITY_PREVIEW_LINES: usize = 200;
#[derive(Debug, Default, Clone)]
pub(crate) struct ReasoningSummaryGroup {
items: Vec<(ActivityId, Vec<String>)>,
}
impl ReasoningSummaryGroup {
pub(crate) fn reset(&mut self) {
self.items.clear();
}
pub(crate) fn append_identity_event(
&mut self,
id: ActivityId,
parent_id: Option<ActivityId>,
text: &str,
) -> Option<ActivityEvent> {
let lines = text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if lines.is_empty() {
return None;
}
let entry = self.items.iter_mut().find(|(entry_id, _)| *entry_id == id);
let lines = if let Some((_, existing)) = entry {
*existing = lines;
existing.clone()
} else {
self.items.push((id.clone(), lines));
self.items
.last()
.map(|(_, lines)| lines.clone())
.unwrap_or_default()
};
let metadata = ActivityMetadata::reasoning(&lines);
Some(ActivityEvent::Started {
id,
parent_id,
kind: ActivityKind::Assistant,
status: ActivityStatus::Success,
metadata,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ActivityParentProvenance {
ExplicitRoot,
Declared(ActivityId),
Unavailable,
}
#[derive(Debug, Clone)]
pub(crate) struct ActivityReasoningSummary {
pub(crate) count: Option<usize>,
pub(crate) latest: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SubagentDisplayState {
pub(crate) context: Option<crate::tui::state::ContextUsageState>,
pub(crate) started: Option<std::time::Instant>,
pub(crate) elapsed: std::time::Duration,
pub(crate) finished: bool,
}
impl SubagentDisplayState {
pub(crate) fn observe_status(&mut self, status: ActivityStatus, now: std::time::Instant) {
if self.finished {
return;
}
if matches!(status, ActivityStatus::Running | ActivityStatus::Writing) {
self.started.get_or_insert(now);
}
if let Some(started) = self.started {
self.elapsed = now.saturating_duration_since(started);
}
self.finished = matches!(
status,
ActivityStatus::Success | ActivityStatus::Failed | ActivityStatus::Canceled
);
}
}
#[derive(Debug, Clone)]
pub(crate) struct ActivityNode {
pub(crate) id: ActivityId,
pub(crate) parent_id: Option<ActivityId>,
pub(crate) parent_provenance: ActivityParentProvenance,
pub(crate) kind: ActivityKind,
pub(crate) status: ActivityStatus,
pub(crate) started_at: chrono::DateTime<chrono::Local>,
pub(crate) update_sequence: u64,
pub(crate) own_update_sequence: u64,
pub(crate) preview: String,
pub(crate) preview_truncated: bool,
pub(crate) tool_detail: Option<crate::output::ToolActivityDetail>,
pub(crate) hash_edit_tree_summary: Option<HashEditActivityTreeSummary>,
pub(crate) metadata: ActivityMetadata,
pub(crate) subagent_display: SubagentDisplayState,
pub(crate) usage: Option<crate::output::NormalizedUsageAggregate>,
pub(crate) last_known_cache_percent: Option<u64>,
pub(crate) last_known_latest_cache_percent: Option<u64>,
pub(crate) transcript_tool_name: Option<String>,
pub(crate) transcript_subagent: Option<super::transcript::TranscriptSubagentSummary>,
pub(crate) transcript_reasoning: Option<ActivityReasoningSummary>,
pub(crate) transcript_bash_command: Option<String>,
}
pub(crate) fn cache_read_percent(usage: crate::output::NormalizedUsageSnapshot) -> Option<u64> {
if !usage.cache_known || usage.effective_input == 0 || usage.cache_read > usage.effective_input
{
return None;
}
Some((u128::from(usage.cache_read) * 100 / u128::from(usage.effective_input)) as u64)
}
impl ActivityNode {
pub(crate) fn refresh_transcript_data(&mut self) {
self.transcript_tool_name = match self.kind {
ActivityKind::SubagentBatch => Some("subagents".to_string()),
ActivityKind::Tool => self
.tool_detail
.as_ref()
.map(|detail| detail.tool_name.trim())
.filter(|name| !name.is_empty())
.or_else(|| {
self.metadata
.label
.split(['•', '·'])
.next()?
.split_whitespace()
.next()
})
.map(str::to_string),
_ => None,
};
self.transcript_subagent = (self.kind == ActivityKind::SubagentTask)
.then(|| super::transcript::subagent_summary(&self.metadata.label, Some(self.status)));
self.transcript_reasoning = (self.kind == ActivityKind::Assistant
&& self.metadata.label.starts_with("reasoning"))
.then(|| {
let count = self
.metadata
.label
.trim()
.strip_prefix("reasoning summaries ×")
.and_then(|count| count.parse::<usize>().ok());
let latest = self
.metadata
.detail
.as_deref()
.and_then(|detail| detail.lines().rev().find(|line| !line.trim().is_empty()))
.filter(|_| {
count.is_some()
|| !normalize_inline_separators(&self.metadata.label).contains('•')
})
.map(str::trim)
.map(|line| {
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
line.get(digits..)
.and_then(|rest| rest.strip_prefix('.'))
.map(str::trim_start)
.unwrap_or(line)
.to_string()
});
ActivityReasoningSummary { count, latest }
});
self.transcript_bash_command = self
.tool_detail
.as_ref()
.and_then(|detail| {
["command", "cmd"].iter().find_map(|key| {
detail
.params
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
})
})
.or_else(|| {
self.preview.lines().find_map(|line| {
line.strip_prefix("COMMAND:")
.map(str::trim)
.filter(|command| !command.is_empty())
.map(sanitize_preview)
})
})
.or_else(|| {
["bash", "shell"].iter().find_map(|prefix| {
self.metadata
.label
.trim()
.strip_prefix(prefix)
.map(str::trim)
.filter(|command| !command.is_empty())
.map(sanitize_preview)
})
});
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ActivityDetailLines {
pub(crate) summary: Vec<DisplayLine>,
pub(crate) body: Vec<DisplayLine>,
pub(crate) metadata_header: Option<usize>,
}
pub(crate) fn disclosure_marker(expanded: bool) -> &'static str {
if expanded { "▾" } else { "▸" }
}
pub(crate) fn activity_detail_lines(
node: &ActivityNode,
metadata_expanded: bool,
) -> ActivityDetailLines {
if node.kind == ActivityKind::SubagentTask {
return subagent_task_detail_lines(node);
}
if let Some(detail) = &node.tool_detail {
return tool_activity_detail_lines(detail, metadata_expanded);
}
let mut summary = vec![
field_line(
"status",
format!(
"{} {}",
activity_status_mark(node.status),
activity_status_label(node.status)
),
),
field_line("id", node.id.as_str()),
field_line("type", activity_kind_label(&node.kind)),
field_line(
"label",
normalize_inline_separators(&redact_sensitive_text(&node.metadata.label)),
),
];
let mut body = Vec::new();
if let Some(detail) = &node.metadata.detail {
let redacted_detail = redact_sensitive_text(detail);
let detail_lines = redacted_detail.lines().collect::<Vec<_>>();
if detail_lines.len() > 1 && node.metadata.label.starts_with("reasoning summaries ×") {
summary.push(field_line(
"summary",
format!("{} lines", detail_lines.len()),
));
body.push(section_line("Detail"));
body.extend(detail_lines.into_iter().map(DisplayLine::plain));
} else {
summary.push(field_line(
"summary",
normalize_inline_separators(&redacted_detail),
));
}
}
let metadata_header = if node.metadata.fields.is_empty() {
None
} else {
let header = body.len();
body.push(section_line(format!(
"{} Metadata",
disclosure_marker(metadata_expanded)
)));
if metadata_expanded {
for (key, value) in &node.metadata.fields {
body.push(field_line(
display_metadata_key(key),
redact_activity_metadata_value(key, value),
));
}
}
Some(header)
};
if !node.preview.is_empty() {
if !body.is_empty() {
body.push(DisplayLine::plain(""));
}
body.push(section_line("Sanitized output preview"));
body.extend(render_activity_preview_lines(&node.preview));
}
ActivityDetailLines {
summary,
body,
metadata_header,
}
}
fn subagent_task_detail_lines(node: &ActivityNode) -> ActivityDetailLines {
let mut summary = vec![
field_line(
"status",
format!(
"{} {}",
activity_status_mark(node.status),
activity_status_label(node.status)
),
),
field_line("id", node.id.as_str()),
field_line("type", "subagent task"),
field_line(
"label",
normalize_inline_separators(&redact_sensitive_text(&node.metadata.label)),
),
];
for (key, value) in &node.metadata.fields {
if matches!(key.as_str(), "identity" | "agent") {
summary.push(field_line(
display_metadata_key(key),
redact_activity_metadata_value(key, value),
));
}
}
let mut body = Vec::new();
if !node.preview.is_empty() {
body.push(section_line("Sanitized output preview"));
body.extend(render_activity_preview_lines(&redact_sensitive_text(
&node.preview,
)));
}
ActivityDetailLines {
summary,
body,
metadata_header: None,
}
}
fn tool_activity_detail_lines(
detail: &crate::output::ToolActivityDetail,
metadata_expanded: bool,
) -> ActivityDetailLines {
let summary = vec![
DisplayLine::plain(format!("Status: {}", activity_status_label(detail.status))),
DisplayLine::plain(format!(
"Tool • {} • {}",
redact_sensitive_text(&detail.tool_name),
detail.label
)),
];
let params = serde_json::json!({ "params": detail.params, "metadata": detail.metadata });
let json = serde_json::to_string_pretty(¶ms).unwrap_or_else(|_| "{}".to_string());
let separator = DisplayLine::plain("─");
let mut body = vec![separator.clone()];
let metadata_header = body.len();
body.push(section_line(format!(
"{} Metadata / Params",
disclosure_marker(metadata_expanded)
)));
if metadata_expanded {
body.extend(crate::rendering::render(
&json,
crate::rendering::RenderInputKind::Code {
language: Some("json".to_string()),
},
));
}
body.push(separator);
body.push(section_line("Sanitized Output:"));
body.extend(crate::rendering::render(
&detail.output,
crate::rendering::RenderInputKind::Plain,
));
if let Some(diff) = &detail.applied_diff {
body.push(section_line("Applied Diff:"));
body.extend(crate::rendering::render(
diff,
crate::rendering::RenderInputKind::Diff,
));
}
ActivityDetailLines {
summary,
body,
metadata_header: Some(metadata_header),
}
}
fn section_line<T: Into<String>>(text: T) -> DisplayLine {
DisplayLine::from_span(text, DisplayRole::Heading)
}
fn field_line(label: impl AsRef<str>, value: impl AsRef<str>) -> DisplayLine {
DisplayLine {
spans: vec![
DisplaySpan::new(label.as_ref(), DisplayRole::ListMarker),
DisplaySpan::new(": ", DisplayRole::Punctuation),
DisplaySpan::new(value.as_ref(), DisplayRole::Plain),
],
table: None,
}
}
fn render_activity_preview_lines(preview: &str) -> Vec<DisplayLine> {
let mut out = Vec::new();
let mut plain = Vec::new();
let mut diff = Vec::new();
let mut in_diff = false;
for line in preview.split('\n') {
if line == "DIFF:" || line == "Diff preview" {
flush_plain(&mut out, &mut plain);
in_diff = true;
out.push(section_line("Diff preview"));
continue;
}
if in_diff {
diff.push(line.to_string());
} else {
plain.push(line.to_string());
}
}
flush_plain(&mut out, &mut plain);
if !diff.is_empty() {
while diff.last().is_some_and(|line| line.is_empty()) {
diff.pop();
}
out.extend(render(
&diff.join("\n"),
crate::rendering::RenderInputKind::Diff,
));
}
out
}
fn flush_plain(out: &mut Vec<DisplayLine>, lines: &mut Vec<String>) {
if lines.is_empty() {
return;
}
while lines.last().is_some_and(|line| line.is_empty()) {
lines.pop();
}
out.extend(lines.drain(..).map(DisplayLine::plain));
}
pub(crate) fn activity_status_mark(status: ActivityStatus) -> &'static str {
match status {
ActivityStatus::Queued => "○",
ActivityStatus::Running | ActivityStatus::Writing => "●",
ActivityStatus::Success => "✓",
ActivityStatus::Failed => "✗",
ActivityStatus::Canceled => "⊘",
}
}
fn activity_status_label(status: ActivityStatus) -> &'static str {
match status {
ActivityStatus::Queued => "QUEUED",
ActivityStatus::Running => "RUNNING",
ActivityStatus::Writing => "WRITING",
ActivityStatus::Success => "SUCCESS",
ActivityStatus::Failed => "FAILED",
ActivityStatus::Canceled => "CANCELED",
}
}
fn activity_kind_label(kind: &ActivityKind) -> &'static str {
match kind {
ActivityKind::Tool => "tool",
ActivityKind::Hook => "hook",
ActivityKind::ProviderContextInjection => "provider context injection",
ActivityKind::SubagentBatch => "subagent batch",
ActivityKind::SubagentTask => "subagent task",
ActivityKind::Assistant => "assistant",
ActivityKind::Diagnostic => "diagnostic",
ActivityKind::Compaction => "compaction",
}
}
pub(crate) fn compact_activity_metadata(fields: &[(String, String)], max_fields: usize) -> String {
fields
.iter()
.take(max_fields)
.map(|(key, value)| compact_activity_metadata_part(key, value))
.collect::<Vec<_>>()
.join(PADDED_INLINE_SEPARATOR)
}
pub(crate) fn compact_activity_tree_metadata(node: &ActivityNode) -> String {
if node.kind == ActivityKind::Tool
&& let Some(summary) = &node.hash_edit_tree_summary
{
return summary.metadata();
}
if node.kind == ActivityKind::Tool
&& let Some(detail) = &node.tool_detail
{
let summary = match detail.tool_name.as_ref() {
"read" => compact_read_activity_detail(detail),
"web" => compact_web_activity_detail(detail),
_ => None,
};
if let Some(summary) = summary {
return summary;
}
}
compact_activity_metadata(&node.metadata.fields, 2)
}
fn compact_web_activity_detail(detail: &crate::output::ToolActivityDetail) -> Option<String> {
let operation = detail
.params
.get("operation")
.and_then(Value::as_str)
.unwrap_or("web");
let target = ["query", "url", "reference"]
.iter()
.find_map(|key| detail.params.get(key).and_then(Value::as_str))
.map(redact_sensitive_text)
.map(|value| normalize_inline_separators(&value));
let results = detail
.metadata
.get("results")
.and_then(Value::as_u64)
.map(|count| format!("{count} results"));
[Some(operation.to_string()), target, results]
.into_iter()
.flatten()
.reduce(|left, right| format!("{left}{PADDED_INLINE_SEPARATOR}{right}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HashEditActivityTreeSummary {
label: String,
metadata: String,
}
impl HashEditActivityTreeSummary {
fn parsed(operation_count: usize, operations: Vec<String>) -> Self {
let mut metadata = operations
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let omitted = operation_count.saturating_sub(operations.len());
if omitted > 0 {
metadata.push_str(&format!(" … +{omitted}"));
}
Self {
label: format!(
"hash_edit {operation_count} {}",
if operation_count == 1 {
"file"
} else {
"files"
}
),
metadata,
}
}
fn malformed(input_bytes: usize) -> Self {
Self {
label: "hash_edit".to_string(),
metadata: format!("bytes {input_bytes}"),
}
}
pub(crate) fn label(&self) -> String {
self.label.clone()
}
pub(crate) fn metadata(&self) -> String {
self.metadata.clone()
}
}
pub(crate) fn hash_edit_activity_tree_summary(
detail: &crate::output::ToolActivityDetail,
) -> Option<HashEditActivityTreeSummary> {
if detail.tool_name.as_ref() != "hash_edit" {
return None;
}
let input = detail.params.get("input")?.as_str()?;
let operations = crate::tools::hash_edit_operation_summaries(input);
let operation_count = operations.len();
if operation_count == 0 {
let input_bytes = detail
.metadata
.get("input_bytes")
.and_then(Value::as_u64)
.and_then(|bytes| usize::try_from(bytes).ok())
.unwrap_or(input.len());
return Some(HashEditActivityTreeSummary::malformed(input_bytes));
}
let mut raw_paths = Vec::new();
for operation in &operations {
match operation {
crate::tools::HashEditOperationSummary::Update { path }
| crate::tools::HashEditOperationSummary::Delete { path } => {
raw_paths.push(path.as_str());
}
crate::tools::HashEditOperationSummary::Move { path, destination } => {
raw_paths.push(path.as_str());
raw_paths.push(destination.as_str());
}
}
}
let sanitized_paths = raw_paths
.into_iter()
.map(sanitize_hash_edit_activity_path)
.collect::<Vec<_>>();
let mut next_path = 0;
let formatted_operations = operations
.iter()
.take(2)
.map(|operation| match operation {
crate::tools::HashEditOperationSummary::Update { .. } => {
let path = shortest_unique_activity_path_suffix(next_path, &sanitized_paths, false);
next_path += 1;
format!("Update {path}")
}
crate::tools::HashEditOperationSummary::Move { .. } => {
let path = shortest_unique_activity_path_suffix(next_path, &sanitized_paths, false);
let destination =
shortest_unique_activity_path_suffix(next_path + 1, &sanitized_paths, false);
next_path += 2;
format!("Move {path} → {destination}")
}
crate::tools::HashEditOperationSummary::Delete { .. } => {
let path = shortest_unique_activity_path_suffix(next_path, &sanitized_paths, false);
next_path += 1;
format!("Delete {path}")
}
})
.collect::<Vec<_>>();
Some(HashEditActivityTreeSummary::parsed(
operation_count,
formatted_operations,
))
}
fn sanitize_hash_edit_activity_path(path: &str) -> String {
let path = sanitize_read_activity_target(path).trim().to_string();
if path.is_empty() {
"<redacted>".to_string()
} else {
path
}
}
fn compact_activity_metadata_part(key: &str, value: &str) -> String {
let value = normalize_inline_separators(&redact_activity_metadata_value(key, value));
match key {
"identity" => format!("identity: {value}"),
_ => format!("{} {value}", display_metadata_key(key)),
}
}
fn display_metadata_key(key: &str) -> String {
match key {
"exit_code" => "exit".to_string(),
"output_lines" => "lines".to_string(),
"edit_count" => "edits".to_string(),
"matches_returned" => "matches".to_string(),
other => other.replace('_', " "),
}
}
fn redact_activity_metadata_value(key: &str, value: &str) -> String {
if is_credential_like_key(key) {
"<redacted>".to_string()
} else {
redact_sensitive_text(value)
}
}
pub(crate) fn truncate_display_width(value: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(value) <= max_width {
return value.to_string();
}
let marker_width = UnicodeWidthStr::width("…");
if max_width <= marker_width {
return "…".to_string();
}
let content_width = max_width - marker_width;
let mut truncated = String::new();
let mut width = 0usize;
for grapheme in value.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 append_preview(node: &mut ActivityNode, preview: &str) {
if node.preview_truncated {
return;
}
let mut combined = String::with_capacity(node.preview.len().saturating_add(preview.len()));
combined.push_str(&node.preview);
combined.push_str(&sanitize_preview(preview));
let (bounded, truncated) = bound_preview(&combined);
node.preview = bounded;
node.preview_truncated |= truncated;
}
pub(crate) fn sanitize_and_bound_preview(preview: &str) -> String {
let sanitized = sanitize_preview(preview);
bound_preview(&sanitized).0
}
fn bound_preview(text: &str) -> (String, bool) {
let mut truncated = false;
let mut by_lines = String::new();
for (index, line) in text.split_inclusive('\n').enumerate() {
if index >= MAX_ACTIVITY_PREVIEW_LINES {
truncated = true;
break;
}
by_lines.push_str(line);
}
if !truncated && text.lines().count() > MAX_ACTIVITY_PREVIEW_LINES {
truncated = true;
}
if by_lines.len() > MAX_ACTIVITY_PREVIEW_BYTES {
truncated = true;
}
let mut out = String::new();
for ch in by_lines.chars() {
if out.len().saturating_add(ch.len_utf8()) > MAX_ACTIVITY_PREVIEW_BYTES {
truncated = true;
break;
}
out.push(ch);
}
(out, truncated)
}