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},
};
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 mut metadata = ActivityMetadata::new("reasoning summary");
metadata.detail = Some(numbered_reasoning_lines(&lines));
Some(ActivityEvent::Started {
id,
parent_id,
kind: ActivityKind::Assistant,
status: ActivityStatus::Success,
metadata,
})
}
}
pub(crate) fn numbered_reasoning_lines(lines: &[String]) -> String {
lines
.iter()
.enumerate()
.map(|(index, line)| format!("{}. {line}", index + 1))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone)]
pub(crate) struct ActivityNode {
pub(crate) id: ActivityId,
pub(crate) parent_id: Option<ActivityId>,
pub(crate) kind: ActivityKind,
pub(crate) status: ActivityStatus,
pub(crate) metadata: ActivityMetadata,
pub(crate) started_at: chrono::DateTime<chrono::Local>,
pub(crate) preview: String,
pub(crate) preview_truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ActivityDetailLines {
pub(crate) summary: Vec<DisplayLine>,
pub(crate) body: Vec<DisplayLine>,
}
pub(crate) fn activity_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", 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()),
));
if !body.is_empty() {
body.push(DisplayLine::plain(""));
}
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),
));
}
}
if !node.metadata.fields.is_empty() {
body.push(section_line("Metadata"));
for (key, value) in &node.metadata.fields {
body.push(field_line(
display_metadata_key(key),
redact_activity_metadata_value(key, value),
));
}
}
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 }
}
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)
}
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(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;
}
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)
}
pub(super) fn final_preview_detail(tool_name: &str) -> String {
match tool_name {
"bash" | "shell" => "bash preview: final output only".to_string(),
_ => "final tool output preview".to_string(),
}
}
#[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));
}
}