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)]
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) 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),
],
}
}
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),
_ => None,
};
if let Some(summary) = summary {
return summary;
}
}
compact_activity_metadata(&node.metadata.fields, 2)
}
#[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
}
}
#[derive(Debug, Clone)]
struct ReadActivityTarget {
target: String,
selector_start: Option<usize>,
has_canonical_metadata: bool,
}
fn compact_read_activity_detail(detail: &crate::output::ToolActivityDetail) -> Option<String> {
if let Some((offset, targets)) = canonical_read_activity_targets(&detail.metadata) {
return compact_read_activity_targets(&targets, Some(offset));
}
if matches!(
detail.status,
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing
) {
return None;
}
compact_read_activity_request(&detail.params)
}
fn canonical_read_activity_targets(value: &Value) -> Option<(usize, Vec<ReadActivityTarget>)> {
let root = value.as_object()?;
let offset = positive_read_activity_number(root.get("offset"))?;
let entries = match root.get("results") {
Some(results) => results.as_array()?.iter().collect::<Vec<_>>(),
None => vec![value],
};
let targets = entries
.into_iter()
.filter_map(|entry| {
let object = entry.as_object()?;
let requested_target = first_nonempty_read_activity_string(object, &["requested_path"]);
let metadata_target = first_nonempty_read_activity_string(object, &["source", "path"]);
let failed = object.get("success").and_then(Value::as_bool) == Some(false);
let target = if failed {
requested_target.or(metadata_target)?
} else {
metadata_target.or(requested_target)?
};
let fallback_raw = match object.get("raw") {
None => Some(false),
Some(Value::Bool(raw)) => Some(*raw),
Some(_) => None,
};
let selector_metadata = fallback_raw.and_then(|fallback_raw| {
read_activity_selector_metadata(object.get("selector"), fallback_raw)
});
Some(ReadActivityTarget {
target: target.to_string(),
selector_start: selector_metadata.and_then(|metadata| metadata.start),
has_canonical_metadata: !failed
&& metadata_target.is_some()
&& selector_metadata.is_some(),
})
})
.collect::<Vec<_>>();
(!targets.is_empty()).then_some((offset, targets))
}
fn first_nonempty_read_activity_string<'a>(
object: &'a serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<&'a str> {
keys.iter().find_map(|key| {
object
.get(*key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
})
}
fn positive_read_activity_number(value: Option<&Value>) -> Option<usize> {
value?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
.filter(|value| *value > 0)
}
#[derive(Debug, Clone, Copy)]
struct ReadActivitySelector {
start: Option<usize>,
}
fn read_activity_selector_metadata(
selector: Option<&Value>,
fallback_raw: bool,
) -> Option<ReadActivitySelector> {
let selector = selector?;
if selector.is_null() {
return Some(ReadActivitySelector {
start: fallback_raw.then_some(1),
});
}
let selector = selector.as_object()?;
let raw = match selector.get("raw") {
None => fallback_raw,
Some(Value::Bool(raw)) => *raw,
Some(_) => return None,
};
let lines = match selector.get("lines") {
None | Some(Value::Null) => {
return Some(ReadActivitySelector {
start: raw.then_some(1),
});
}
Some(Value::Object(lines)) => lines,
Some(_) => return None,
};
let start = match lines.get("type").and_then(Value::as_str)? {
"single" => positive_read_activity_number(lines.get("line"))?,
"range" => {
let start = positive_read_activity_number(lines.get("start"))?;
let end = positive_read_activity_number(lines.get("end"))?;
(end >= start).then_some(start)?
}
"from_start" => {
positive_read_activity_number(lines.get("end"))?;
1
}
"from_line" => positive_read_activity_number(lines.get("start"))?,
"count" => {
let start = positive_read_activity_number(lines.get("start"))?;
positive_read_activity_number(lines.get("count"))?;
start
}
_ => return None,
};
Some(ReadActivitySelector { start: Some(start) })
}
fn compact_read_activity_request(params: &Value) -> Option<String> {
let params = params.as_object()?;
let targets = match params.get("paths") {
Some(paths) if !paths.is_null() => {
if params.get("path").is_some_and(|path| !path.is_null()) {
return None;
}
paths
.as_array()?
.iter()
.map(Value::as_str)
.collect::<Option<Vec<_>>>()?
}
Some(Value::Null) | None => vec![params.get("path")?.as_str()?],
Some(_) => return None,
};
if targets.is_empty() {
return None;
}
let targets = targets
.into_iter()
.map(|target| ReadActivityTarget {
target: target.to_string(),
selector_start: None,
has_canonical_metadata: false,
})
.collect::<Vec<_>>();
compact_read_activity_targets(&targets, None)
}
fn compact_read_activity_targets(
targets: &[ReadActivityTarget],
offset: Option<usize>,
) -> Option<String> {
let sanitized_targets = targets
.iter()
.map(|target| sanitize_read_activity_target(&target.target))
.collect::<Vec<_>>();
let summaries = sanitized_targets
.iter()
.enumerate()
.filter_map(|(index, target)| {
if target.is_empty() {
return None;
}
let display_target = if is_read_activity_resource(target) {
target.clone()
} else {
shortest_unique_activity_path_suffix(index, &sanitized_targets, true)
};
if display_target.is_empty() {
return None;
}
let start = if targets[index].has_canonical_metadata {
targets[index].selector_start.or_else(|| {
if is_read_activity_resource(target) {
None
} else {
offset
}
})
} else {
None
};
Some(match start {
Some(start) => format!("{display_target}:{start}"),
None => display_target,
})
})
.collect::<Vec<_>>();
let mut summary = summaries
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if summary.is_empty() {
return None;
}
if targets.len() > 2 {
summary.push_str(" …");
}
Some(summary)
}
fn is_read_activity_resource(target: &str) -> bool {
target.contains("://")
}
fn shortest_unique_activity_path_suffix(
index: usize,
targets: &[String],
preserve_resources: bool,
) -> String {
let target = &targets[index];
if preserve_resources && is_read_activity_resource(target) {
return target.clone();
}
let segments = activity_path_segments(target);
for length in 1..=segments.len() {
let candidate = segments[segments.len() - length..].join("/");
let suffix = &segments[segments.len() - length..];
let collides = targets.iter().enumerate().any(|(other_index, other)| {
if other_index == index
|| other.is_empty()
|| (preserve_resources && is_read_activity_resource(other))
{
return false;
}
let other_segments = activity_path_segments(other);
other_segments.len() >= length
&& other_segments[other_segments.len() - length..] == *suffix
});
if !collides {
return candidate;
}
}
segments.join("/")
}
fn activity_path_segments(target: &str) -> Vec<&str> {
target
.trim_end_matches(['/', '\\'])
.split(['/', '\\'])
.filter(|segment| !segment.is_empty())
.collect()
}
pub(crate) fn sanitize_read_activity_label(label: &str) -> String {
let label = crate::output::sanitize_display_text(label);
label.strip_prefix("read ").map_or_else(
|| sanitize_read_activity_target(&label),
|target| format!("read {}", sanitize_read_activity_target(target)),
)
}
fn sanitize_read_activity_target(target: &str) -> String {
let sanitized = crate::output::sanitize_display_text(target)
.chars()
.filter(|character| !character.is_control())
.collect::<String>();
let trimmed = sanitized.trim();
if is_http_activity_url(trimmed) {
if let Ok(mut url) = reqwest::Url::parse(trimmed) {
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
return normalize_inline_separators(url.as_ref());
}
return normalize_inline_separators(&strip_http_activity_url_parts(trimmed));
}
normalize_inline_separators(&sanitized)
}
fn is_http_activity_url(target: &str) -> bool {
target
.get(.."http://".len())
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("http://"))
|| target
.get(.."https://".len())
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"))
}
fn strip_http_activity_url_parts(target: &str) -> String {
let mut safe = target
.split(['?', '#'])
.next()
.unwrap_or(target)
.to_string();
let Some(scheme_end) = safe.find("://") else {
return safe;
};
let authority_start = scheme_end + 3;
let authority_end = safe[authority_start..]
.find('/')
.map_or(safe.len(), |end| authority_start + end);
if let Some(userinfo_end) = safe[authority_start..authority_end].rfind('@') {
safe.replace_range(authority_start..authority_start + userinfo_end + 1, "");
}
safe
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
const OLD_ACTIVITY_PREVIEW_TRUNCATED_MARKER: &str = "[… activity preview truncated …]";
#[test]
fn bound_preview_limits_bytes_without_visible_marker() {
let (bounded, truncated) = bound_preview(&"x".repeat(MAX_ACTIVITY_PREVIEW_BYTES + 1));
assert!(truncated);
assert!(bounded.len() <= MAX_ACTIVITY_PREVIEW_BYTES);
assert!(!bounded.contains(OLD_ACTIVITY_PREVIEW_TRUNCATED_MARKER));
}
#[test]
fn bound_preview_limits_lines_without_visible_marker() {
let (bounded, truncated) = bound_preview(&"line\n".repeat(MAX_ACTIVITY_PREVIEW_LINES + 20));
assert!(truncated);
assert!(bounded.lines().count() <= MAX_ACTIVITY_PREVIEW_LINES);
assert!(!bounded.contains(OLD_ACTIVITY_PREVIEW_TRUNCATED_MARKER));
}
#[test]
fn disclosure_marker_matches_tree_and_detail_states() {
assert_eq!(disclosure_marker(false), "▸");
assert_eq!(disclosure_marker(true), "▾");
}
fn activity_node_for_tree_metadata(
kind: ActivityKind,
tool_name: &str,
params: Value,
fields: Vec<(String, String)>,
) -> ActivityNode {
ActivityNode {
id: ActivityId::new("activity"),
transcript_tool_name: None,
transcript_subagent: None,
transcript_reasoning: None,
transcript_bash_command: None,
parent_id: None,
parent_provenance: ActivityParentProvenance::Unavailable,
kind,
status: ActivityStatus::Success,
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: Some(crate::output::ToolActivityDetail {
tool_name: tool_name.into(),
label: "read 1 file".into(),
params,
metadata: Value::Null,
status: ActivityStatus::Success,
output: "".into(),
applied_diff: None,
}),
hash_edit_tree_summary: None,
metadata: ActivityMetadata {
label: "read 1 file".to_string(),
detail: None,
fields,
},
}
}
fn read_activity_detail(params: Value, metadata: Value) -> crate::output::ToolActivityDetail {
crate::output::ToolActivityDetail {
tool_name: "read".into(),
label: "read file".into(),
params,
metadata,
status: ActivityStatus::Success,
output: String::new().into(),
applied_diff: None,
}
}
fn hash_edit_activity_detail(
input: &str,
status: ActivityStatus,
) -> crate::output::ToolActivityDetail {
crate::output::ToolActivityDetail {
tool_name: "hash_edit".into(),
label: "hash_edit".into(),
params: serde_json::json!({"input": input}),
metadata: Value::Null,
status,
output: String::new().into(),
applied_diff: None,
}
}
fn hash_edit_activity_node(input: &str, fields: Vec<(String, String)>) -> ActivityNode {
let detail = hash_edit_activity_detail(input, ActivityStatus::Success);
let hash_edit_tree_summary = hash_edit_activity_tree_summary(&detail);
let mut node = activity_node_for_tree_metadata(
ActivityKind::Tool,
"hash_edit",
serde_json::json!({"input": input}),
fields,
);
node.tool_detail = Some(detail);
node.hash_edit_tree_summary = hash_edit_tree_summary;
node
}
#[test]
fn hash_edit_activity_tree_summary_formats_stable_operation_verbs() {
for (input, expected_label, expected_metadata) in [
(
"[activity.rs#ABCD]\nSWAP 1.=1:\n+new",
"hash_edit 1 file",
"Update activity.rs",
),
(
"[old.rs#ABCD]\nMV new.rs",
"hash_edit 1 file",
"Move old.rs → new.rs",
),
(
"[legacy.rs#ABCD]\nREM",
"hash_edit 1 file",
"Delete legacy.rs",
),
] {
let detail = hash_edit_activity_detail(input, ActivityStatus::Running);
assert_eq!(
hash_edit_activity_tree_summary(&detail).map(|summary| summary.label()),
Some(expected_label.to_string())
);
assert_eq!(
compact_activity_tree_metadata(&hash_edit_activity_node(
input,
vec![("bytes".to_string(), "99".to_string())],
)),
expected_metadata
);
}
}
#[test]
fn hash_edit_activity_tree_summary_truncates_after_two_operations() {
let input =
"[old.rs#ABCD]\nMV new.rs\n[tests.rs#ABCD]\nSWAP 1.=1:\n+new\n[legacy.rs#ABCD]\nREM";
let detail = hash_edit_activity_detail(input, ActivityStatus::Success);
assert_eq!(
hash_edit_activity_tree_summary(&detail).map(|summary| summary.label()),
Some("hash_edit 3 files".to_string())
);
assert_eq!(
compact_activity_tree_metadata(&hash_edit_activity_node(
input,
vec![("bytes".to_string(), "99".to_string())],
)),
"Move old.rs → new.rs, Update tests.rs … +1"
);
}
#[test]
fn hash_edit_activity_tree_summary_uses_unique_suffixes_for_all_paths() {
let input = "[a/mod.rs#ABCD]\nMV b/mod.rs\n[c/mod.rs#ABCD]\nSWAP 1.=1:\n+new";
assert_eq!(
compact_activity_tree_metadata(&hash_edit_activity_node(input, Vec::new(),)),
"Move a/mod.rs → b/mod.rs, Update c/mod.rs"
);
}
#[test]
fn hash_edit_activity_tree_summary_falls_back_for_malformed_input() {
let input = "not a hashline patch";
let node = hash_edit_activity_node(input, vec![("bytes".to_string(), "42".to_string())]);
assert_eq!(compact_activity_tree_metadata(&node), "bytes 20");
assert_eq!(
hash_edit_activity_tree_summary(node.tool_detail.as_ref().unwrap())
.map(|summary| summary.label()),
Some("hash_edit".to_string())
);
assert_eq!(
node.hash_edit_tree_summary
.as_ref()
.map(|summary| summary.metadata()),
Some("bytes 20".to_string())
);
}
#[test]
fn hash_edit_activity_tree_summary_uses_metadata_input_bytes_after_param_redaction() {
let secret = "credential-value";
let input = format!("not a hashline patch api_key={secret}");
let call = crate::providers::ToolCall {
id: "hash-edit-result-only".to_string(),
name: "hash_edit".to_string(),
arguments: serde_json::json!({"input": input.clone()}),
};
let result = crate::tools::ToolResult {
tool_name: "hash_edit".to_string(),
success: false,
content: String::new(),
metadata: serde_json::json!({"input_bytes": input.len()}),
display: crate::tools::ToolResultDisplay::default(),
};
let detail = crate::tool_display::tool_activity_detail(
&call,
&result,
"hash_edit",
ActivityStatus::Failed,
);
let sanitized_input = detail.params["input"].as_str().expect("input parameter");
assert_ne!(sanitized_input.len(), input.len());
assert!(!sanitized_input.contains(secret));
let summary = hash_edit_activity_tree_summary(&detail).expect("malformed summary");
assert_eq!(summary.metadata(), format!("bytes {}", input.len()));
}
#[test]
fn hash_edit_activity_tree_summary_sanitizes_paths_and_omits_patch_body() {
let input =
"[src/\u{1b}[31mapi_key=supersecret.rs#ABCD]\nSWAP 1.=1:\n+PATCH_BODY_MUST_NOT_RENDER";
let summary = compact_activity_tree_metadata(&hash_edit_activity_node(input, Vec::new()));
assert!(!summary.contains('\u{1b}'));
assert!(!summary.contains("supersecret"));
assert!(summary.contains("<redacted>"), "{summary}");
assert!(!summary.contains("PATCH_BODY_MUST_NOT_RENDER"));
}
#[test]
fn hash_edit_activity_tree_summary_is_preserved_across_statuses() {
let input = "[activity.rs#ABCD]\nSWAP 1.=1:\n+new";
for status in [
ActivityStatus::Running,
ActivityStatus::Success,
ActivityStatus::Failed,
] {
let detail = hash_edit_activity_detail(input, status);
assert_eq!(
hash_edit_activity_tree_summary(&detail).map(|summary| summary.metadata()),
Some("Update activity.rs".to_string())
);
}
}
#[test]
fn compact_read_activity_metadata_uses_canonical_selector_metadata() {
for (metadata, expected) in [
(
serde_json::json!({
"source": "src/filename.ext",
"path": "/workspace/src/filename.ext",
"offset": 99,
"selector": {"raw": false, "lines": {"type": "single", "line": 136}}
}),
"filename.ext:136",
),
(
serde_json::json!({
"source": "src/filename.ext",
"offset": 99,
"selector": {"raw": false, "lines": {"type": "range", "start": 2, "end": 8}}
}),
"filename.ext:2",
),
(
serde_json::json!({
"source": "src/filename.ext",
"offset": 99,
"selector": {"raw": false, "lines": {"type": "from_start", "end": 8}}
}),
"filename.ext:1",
),
(
serde_json::json!({
"source": "src/filename.ext",
"offset": 99,
"selector": {"raw": false, "lines": {"type": "from_line", "start": 4}}
}),
"filename.ext:4",
),
(
serde_json::json!({
"source": "src/filename.ext",
"offset": 99,
"selector": {"raw": false, "lines": {"type": "count", "start": 5, "count": 2}}
}),
"filename.ext:5",
),
(
serde_json::json!({
"source": "src/filename.ext",
"offset": 99,
"selector": {"raw": true, "lines": {"type": "range", "start": 6, "end": 9}}
}),
"filename.ext:6",
),
(
serde_json::json!({
"source": "nested/file.rs",
"offset": 136,
"selector": null,
"raw": false
}),
"file.rs:136",
),
(
serde_json::json!({
"source": "notes:raw",
"path": "/workspace/notes:raw",
"offset": 11,
"selector": null,
"raw": false
}),
"notes:raw:11",
),
] {
assert_eq!(
compact_read_activity_detail(&read_activity_detail(Value::Null, metadata)),
Some(expected.to_string()),
"expected={expected}"
);
}
}
#[test]
fn compact_read_activity_metadata_fallback_does_not_parse_request_selectors() {
let detail = read_activity_detail(
serde_json::json!({"path": "src/notes:raw", "offset": 9}),
serde_json::json!({"offset": 9, "selector": "malformed"}),
);
assert_eq!(
compact_read_activity_detail(&detail),
Some("notes:raw".to_string())
);
}
#[test]
fn compact_read_activity_failed_single_preserves_requested_selector() {
let mut detail = read_activity_detail(
serde_json::json!({"path": "src/file.rs:136", "offset": 1}),
serde_json::json!({}),
);
detail.status = ActivityStatus::Failed;
assert_eq!(
compact_read_activity_detail(&detail),
Some("file.rs:136".to_string())
);
}
#[test]
fn compact_read_activity_partial_failure_does_not_append_to_request_only_targets() {
let mut detail = read_activity_detail(
serde_json::json!({"paths": ["src/file.rs:136", "src/ok.rs", "omitted.rs"]}),
serde_json::json!({
"offset": 1,
"results": [
{
"requested_path": "src/file.rs:136",
"success": false,
"error": "missing"
},
{
"requested_path": "src/ok.rs",
"source": "src/ok.rs",
"success": true,
"selector": null
},
{
"requested_path": "omitted.rs",
"success": false,
"omitted": true
}
]
}),
);
detail.status = ActivityStatus::Failed;
assert_eq!(
compact_read_activity_detail(&detail),
Some("file.rs:136, ok.rs:1 …".to_string())
);
}
#[test]
fn compact_read_activity_metadata_uses_shortest_unique_local_suffixes() {
let metadata = serde_json::json!({
"offset": 7,
"results": [
{"requested_path": "src/a/mod.rs", "source": "src/a/mod.rs", "selector": null},
{"requested_path": "src/b/mod.rs", "source": "src/b/mod.rs", "selector": null},
{"requested_path": "src/c/lib.rs", "source": "src/c/lib.rs", "selector": null}
]
});
assert_eq!(
compact_read_activity_detail(&read_activity_detail(Value::Null, metadata)),
Some("a/mod.rs:7, b/mod.rs:7 …".to_string())
);
}
#[test]
fn compact_read_activity_metadata_strips_mixed_case_signed_url_parts() {
let signed_url = "HTTPS://user:password@example.com/docs?X-Amz-Signature=secret#fragment";
let metadata = serde_json::json!({
"offset": 136,
"source": signed_url,
"selector": null
});
let summary = compact_read_activity_detail(&read_activity_detail(Value::Null, metadata))
.expect("signed URL summary");
assert_eq!(summary, "https://example.com/docs");
assert!(!summary.contains("password"));
assert!(!summary.contains("X-Amz-Signature"));
assert!(!summary.contains("fragment"));
assert_eq!(
sanitize_read_activity_label(&format!("read {signed_url}")),
"read https://example.com/docs"
);
let malformed_url =
"HTTPS://user:password@example.com:badport/docs?X-Amz-Signature=secret#fragment";
let mut failed = read_activity_detail(
serde_json::json!({"path": malformed_url}),
serde_json::json!({}),
);
failed.status = ActivityStatus::Failed;
failed.label = format!("read {malformed_url}").into();
assert_eq!(
compact_read_activity_detail(&failed),
Some("HTTPS://example.com:badport/docs".to_string())
);
assert_eq!(
sanitize_read_activity_label(&failed.label),
"read HTTPS://example.com:badport/docs"
);
}
#[test]
fn compact_read_activity_pending_label_sanitizes_mixed_case_url() {
let signed_url = "HTTPS://user:password@example.com/docs?X-Amz-Signature=secret#fragment";
let mut detail = read_activity_detail(
serde_json::json!({"path": signed_url}),
serde_json::json!({}),
);
detail.status = ActivityStatus::Running;
detail.label = format!("read {signed_url}").into();
assert_eq!(compact_read_activity_detail(&detail), None);
assert_eq!(
sanitize_read_activity_label(&detail.label),
"read https://example.com/docs"
);
}
#[test]
fn compact_read_activity_metadata_preserves_resources_and_truncates_targets() {
assert_eq!(
compact_read_activity_detail(&read_activity_detail(
Value::Null,
serde_json::json!({
"offset": 136,
"results": [
{
"requested_path": "https://example.com/docs?signature=secret#fragment",
"source": "https://example.com/docs?signature=secret#fragment",
"selector": null
},
{
"requested_path": "skill://guide/reference:7",
"source": "skill://guide/reference",
"selector": {"raw": false, "lines": {"type": "single", "line": 7}}
}
]
}),
)),
Some("https://example.com/docs, skill://guide/reference:7".to_string())
);
assert_eq!(
compact_read_activity_detail(&read_activity_detail(
Value::Null,
serde_json::json!({
"offset": 3,
"results": [
{"requested_path": "one.rs", "source": "one.rs", "selector": null},
{"requested_path": "two.rs", "source": "two.rs", "selector": null},
{"requested_path": "three.rs", "source": "three.rs", "selector": null}
]
}),
)),
Some("one.rs:3, two.rs:3 …".to_string())
);
}
#[test]
fn compact_activity_tree_metadata_falls_back_for_invalid_read_and_non_read_nodes() {
let fields = vec![
("files".to_string(), "1".to_string()),
("offset".to_string(), "136".to_string()),
];
let expected = "files 1 • offset 136";
assert_eq!(
compact_activity_tree_metadata(&activity_node_for_tree_metadata(
ActivityKind::Tool,
"read",
serde_json::json!({}),
fields.clone(),
)),
expected
);
assert_eq!(
compact_activity_tree_metadata(&activity_node_for_tree_metadata(
ActivityKind::Tool,
"read",
serde_json::json!({"paths": "not-an-array"}),
fields.clone(),
)),
expected
);
assert_eq!(
compact_activity_tree_metadata(&activity_node_for_tree_metadata(
ActivityKind::Hook,
"read",
serde_json::json!({"paths": ["file.rs"]}),
fields,
)),
expected
);
}
#[test]
fn tool_detail_collapses_only_metadata_section() {
let detail = crate::output::ToolActivityDetail {
tool_name: "read".into(),
label: "read file".into(),
params: serde_json::json!({"path":"secret-path"}),
metadata: serde_json::json!({"lines":3}),
status: ActivityStatus::Success,
output: "visible output".into(),
applied_diff: None,
};
let lines = tool_activity_detail_lines(&detail, false);
let body = lines
.body
.iter()
.map(DisplayLine::plain_text)
.collect::<Vec<_>>();
assert!(body.iter().any(|line| line == "▸ Metadata / Params"));
assert!(!body.iter().any(|line| line.contains("secret-path")));
assert!(body.iter().any(|line| line == "Sanitized Output:"));
assert!(body.iter().any(|line| line == "visible output"));
assert_eq!(lines.metadata_header, Some(1));
}
#[test]
fn tool_detail_uses_exact_section_order_and_diff_roles() {
let detail = crate::output::ToolActivityDetail {
tool_name: "hash_edit".into(),
label: "hash_edit src/lib.rs".into(),
params: serde_json::json!({"input":"patch"}),
metadata: serde_json::json!({"files_changed":1}),
status: ActivityStatus::Success,
output: "applied patch".into(),
applied_diff: Some(
"diff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new".into(),
),
};
let lines = tool_activity_detail_lines(&detail, true);
assert_eq!(lines.summary[0].plain_text(), "Status: SUCCESS");
assert_eq!(
lines.summary[1].plain_text(),
"Tool • hash_edit • hash_edit src/lib.rs"
);
let body = lines
.body
.iter()
.map(DisplayLine::plain_text)
.collect::<Vec<_>>();
let metadata = body
.iter()
.position(|line| line == "▾ Metadata / Params")
.unwrap();
let output = body
.iter()
.position(|line| line == "Sanitized Output:")
.unwrap();
let diff = body
.iter()
.position(|line| line == "Applied Diff:")
.unwrap();
assert!(metadata < output && output < diff, "{body:?}");
assert!(body.iter().any(|line| line == "applied patch"));
assert!(lines.body.iter().any(|line| {
line.plain_text() == "-old"
&& line
.spans
.first()
.is_some_and(|span| span.role == DisplayRole::DiffRemoved)
}));
assert!(lines.body.iter().any(|line| {
line.plain_text() == "+new"
&& line
.spans
.first()
.is_some_and(|span| span.role == DisplayRole::DiffInserted)
}));
}
}