use chrono::TimeZone;
use colored::Colorize;
fn truncate_chars(s: &str, max_chars: usize) -> String {
let mut it = s.chars();
let head: String = it.by_ref().take(max_chars).collect();
if it.next().is_some() {
format!("{}...", head)
} else {
head
}
}
fn squash_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_ws = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !in_ws {
out.push(' ');
in_ws = true;
}
} else {
in_ws = false;
out.push(ch);
}
}
out.trim().to_string()
}
pub fn run(
path: String,
limit: usize,
since: Option<String>,
until: Option<String>,
) -> anyhow::Result<()> {
let ws = crate::workspace::get_or_create_workspace_paths(std::path::Path::new(&path))?;
let since_ms = match since {
Some(ref s) => crate::util::parse_time_filter(s)?,
None => None,
};
let until_ms = match until {
Some(ref u) => crate::util::parse_time_filter(u)?,
None => None,
};
let all_interventions = crate::metrics::get_recent_interventions(&ws.metrics_jsonl, 1000)?;
let filtered: Vec<_> = all_interventions
.into_iter()
.filter(|iv| {
let after = since_ms.map(|s| iv.ts_ms >= s).unwrap_or(true);
let before = until_ms.map(|u| iv.ts_ms <= u).unwrap_or(true);
after && before
})
.collect();
if filtered.is_empty() {
println!(
"{} {}",
"No interventions found for workspace:".dimmed(),
ws.id
);
return Ok(());
}
println!("{} {}", "workspace:".dimmed(), ws.id.bold());
println!();
println!("{}", "Recent friction interventions:".dimmed());
let now = crate::workspace::now_ms();
let blacklist = [
"NOTE",
"REASON",
"DECISION",
"INTENT",
"NEXT_STEPS",
"RATIONALE",
"SYMBOLS",
"USER",
"ASSISTANT",
"SYSTEM",
"SUCCESS",
"FAILURE",
"ERROR",
"WARNING",
];
#[derive(Clone)]
struct Row {
fingerprint: String,
ts_str: String,
ago_str: String,
duration_str: String,
severity: String,
diagnosis: String,
emotion_str: String,
topic_preview: Option<String>,
symbols_str: Option<String>,
count: usize,
}
let mut rows: Vec<Row> = Vec::new();
for iv in filtered.iter() {
let ts_str = chrono::Utc
.timestamp_millis_opt(iv.ts_ms)
.single()
.map(|dt| dt.format("%H:%M").to_string())
.unwrap_or_else(|| "--:--".to_string());
let ago_str = crate::util::format_elapsed_time(iv.ts_ms, now);
let duration_str = if let Some(start) = iv.watch_start_ts {
let dur_mins = (iv.ts_ms - start) / 60000;
if dur_mins > 0 {
format!("Intervened after {}m", dur_mins)
} else {
"Intervened".to_string()
}
} else {
"Intervened".to_string()
};
let diagnosis = crate::metrics::get_diagnosis(&iv.cause, &iv.top_channels);
let severity = crate::metrics::get_severity_label(iv.intensity).to_string();
let emotion_str = if let Some(ref e) = iv.user_emotion {
if crate::governor::FRICTION_EMOTIONS.contains(&e.as_str()) {
format!(" (user {})", e)
} else {
"".to_string()
}
} else {
"".to_string()
};
let mut clean_symbols = Vec::new();
for s in &iv.symbols {
let trimmed = s.trim_matches(|c: char| c == ':' || c == '.' || c == ',');
if blacklist.iter().any(|&b| b == trimmed.to_uppercase()) {
continue;
}
if trimmed.contains('/') && !trimmed.contains('.') {
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.iter().any(|&p| {
p == "read" || p == "write" || p == "inspect" || p == "call" || p == "tool"
}) {
continue;
}
}
if !trimmed.is_empty() {
clean_symbols.push(trimmed.to_string());
}
}
let symbols_str = if clean_symbols.is_empty() {
None
} else if clean_symbols.len() > 3 {
Some(format!(
"{}, {} and {} others",
clean_symbols[0],
clean_symbols[1],
clean_symbols.len() - 2
))
} else {
Some(clean_symbols.join(", "))
};
let topic = squash_whitespace(iv.topic.as_deref().unwrap_or(""));
let topic_preview = if topic.is_empty() {
None
} else {
Some(truncate_chars(&topic, 200))
};
let fingerprint = format!(
"{}|{}|{}|{}|{}|{}",
ts_str,
duration_str,
severity,
diagnosis,
emotion_str,
topic_preview.as_deref().unwrap_or("")
);
let fingerprint = if let Some(ref sym) = symbols_str {
format!("{}|{}", fingerprint, sym)
} else {
fingerprint
};
if let Some(last) = rows.last_mut() {
if last.fingerprint == fingerprint {
last.count += 1;
continue;
}
}
rows.push(Row {
fingerprint,
ts_str,
ago_str,
duration_str,
severity,
diagnosis,
emotion_str,
topic_preview,
symbols_str,
count: 1,
});
}
rows.truncate(limit);
for (i, row) in rows.iter().enumerate() {
let sev = match row.severity.as_str() {
"Acute" => row.severity.red().bold(),
"Strong" => row.severity.yellow().bold(),
_ => row.severity.cyan().bold(),
};
let repeat = if row.count > 1 {
format!(" x{}", row.count).dimmed().to_string()
} else {
String::new()
};
let emotion = if row.emotion_str.contains("disapproval") {
row.emotion_str.red().to_string()
} else if row.emotion_str.contains("frustr") || row.emotion_str.contains("anger") {
row.emotion_str.yellow().to_string()
} else {
row.emotion_str.dimmed().to_string()
};
println!(
" {} {} {} {} {} {}{}{}",
format!("{}.", i + 1).dimmed(),
row.ts_str.bold(),
format!("({})", row.ago_str).dimmed(),
"|".dimmed(),
format!("{}:", row.duration_str).dimmed(),
format!("{} - {}", sev, row.diagnosis.bold()),
emotion,
repeat
);
if let Some(ref topic) = row.topic_preview {
println!(" {} {}", "Topic:".dimmed(), topic);
}
if let Some(ref symbols) = row.symbols_str {
println!(" {} {}", "Symbols:".dimmed(), symbols);
}
}
Ok(())
}