1use super::*;
2use std::fmt::Write;
3
4fn line(text: &str) -> String {
5 text.split_whitespace().collect::<Vec<_>>().join(" ")
6}
7fn short(text: &str) -> String {
8 line(&public_text(text, 240))
9}
10fn markdown_short(text: &str) -> String {
11 let mut escaped = String::new();
12 for character in short(text).chars() {
13 if matches!(
14 character,
15 '\\' | '`'
16 | '*'
17 | '_'
18 | '{'
19 | '}'
20 | '['
21 | ']'
22 | '('
23 | ')'
24 | '<'
25 | '>'
26 | '#'
27 | '+'
28 | '-'
29 | '.'
30 | '!'
31 | '|'
32 | '~'
33 ) {
34 escaped.push('\\');
35 }
36 escaped.push(character);
37 }
38 escaped
39}
40fn evidence_link(e: &EvidenceRef) -> String {
41 format!("[evidence](vtcode-evidence:{}:{}:{})", e.session_id, e.offset, e.digest)
42}
43
44fn graph_labels(model: &ExplanationModel) -> std::collections::BTreeMap<String, String> {
45 let mut labels: std::collections::BTreeMap<_, _> = model
46 .actions
47 .iter()
48 .chain(&model.plan_evolution)
49 .chain(model.decisions.iter().map(|decision| &decision.fact))
50 .map(|entry| (projection::graph_node_id(entry), short(&entry.label)))
51 .collect();
52 for edge in model.graph.iter().filter(|edge| edge.relation == "changed file") {
53 labels.entry(edge.from.clone()).or_insert_with(|| "Recorded edit".into());
54 }
55 labels
56}
57
58pub fn render_details(model: &ExplanationModel) -> String {
60 let mut out =
61 format!("Alt+click a fact to inspect its canonical evidence.\n{}", render_summary_with_labels(model, short));
62 for (name, facts) in [
63 ("Recorded actions", &model.actions),
64 ("Captured changes", &model.changes),
65 ("Plan evolution", &model.plan_evolution),
66 ("Failures", &model.failures),
67 ] {
68 let _ = write!(out, "\n\n{name}\n");
69 for f in facts {
70 let _ = writeln!(out, "- {} — {} {}", line(&f.label), f.status, evidence_link(&f.evidence));
71 }
72 }
73 for d in &model.decisions {
74 let _ = writeln!(
75 out,
76 "\nDecision: {} {}\nAgent-reported rationale: {}\nAlternatives: {}",
77 line(&d.fact.label),
78 evidence_link(&d.fact.evidence),
79 line(&d.rationale),
80 d.alternatives.join("; ")
81 );
82 }
83 for v in &model.verification {
84 let _ = writeln!(
85 out,
86 "\nCheck: {} — {}; exit {:?}; fresh: {} {}",
87 line(&v.fact.label),
88 v.fact.status,
89 v.exit_code,
90 v.fresh,
91 evidence_link(&v.fact.evidence)
92 );
93 }
94 for r in &model.review_priorities {
95 let _ = writeln!(
96 out,
97 "\nReview {:?}: {} — {} {}",
98 r.priority,
99 line(&r.fact.label),
100 r.reason,
101 evidence_link(&r.fact.evidence)
102 );
103 }
104 for entry in &model.token_breakdowns {
105 if let Ok(breakdown) = serde_json::to_string(&entry.breakdown) {
106 let _ = writeln!(out, "\nRequest-prefix tokens: {breakdown} {}", evidence_link(&entry.fact.evidence));
107 }
108 }
109 out
110}
111
112pub fn render_summary(model: &ExplanationModel) -> String {
114 render_summary_with_labels(model, markdown_short)
115}
116
117fn render_summary_with_labels(model: &ExplanationModel, short: fn(&str) -> String) -> String {
118 let mut lines = vec![
119 "Goal".into(),
120 format!(
121 " {} — {}{}",
122 model.goals.last().map_or("Goal unavailable".into(), |g| short(&g.label)),
123 short(&model.status),
124 model
125 .goals
126 .last()
127 .map_or(String::new(), |g| format!(" [{}]", evidence_link(&g.evidence)))
128 ),
129 "Changes".into(),
130 ];
131 if model.changes.is_empty() {
132 lines.push(" No successful file changes recorded.".into());
133 } else {
134 lines.push(format!(" {} distinct files; {} edit operations.", model.changes.len(), model.edit_operations));
135 lines.extend(
136 model
137 .changes
138 .iter()
139 .take(2)
140 .map(|c| format!(" {} ({}) [{}]", short(&c.label), c.status, evidence_link(&c.evidence))),
141 );
142 }
143 lines.push("Decisions".into());
144 lines.push(model.decisions.first().map_or(" Public rationale unavailable.".into(), |d| {
145 format!(
146 " {} — {} (agent-reported) [{}]",
147 short(&d.fact.label),
148 short(&d.rationale),
149 evidence_link(&d.fact.evidence)
150 )
151 }));
152 lines.push("Verification".into());
153 if model.verification.is_empty() {
154 lines.push(" No verification command recorded.".into());
155 } else {
156 lines.extend(model.verification.iter().rev().take(2).map(|v| {
157 format!(
158 " {}: {}{} [{}]",
159 short(&v.fact.label),
160 v.fact.status,
161 if v.fact.status == "passed" && !v.fresh {
162 " (before a later mutation attempt)"
163 } else {
164 ""
165 },
166 evidence_link(&v.fact.evidence)
167 )
168 }));
169 }
170 lines.push("Review first".into());
171 if model.review_priorities.is_empty() {
172 lines.push(" No review signal recorded; this is not a coverage assessment.".into());
173 } else {
174 lines.extend(model.review_priorities.iter().take(2).map(|r| {
175 format!(" {:?}: {} — {} [{}]", r.priority, short(&r.fact.label), r.reason, evidence_link(&r.fact.evidence))
176 }));
177 }
178 if let Some(f) = model.failures.last() {
179 lines.push(format!(
180 "Failures: {} recorded; latest: {} [{}]",
181 model.failures.len(),
182 short(&f.label),
183 evidence_link(&f.evidence)
184 ));
185 }
186 let c = &model.completeness;
187 lines.push(format!(
188 "Evidence: {} malformed, {} unknown, {} legacy, {} evicted turns.",
189 c.malformed_records, c.unknown_records, c.legacy_records, c.evicted_turns
190 ));
191 let categorized_evidence: std::collections::BTreeSet<_> = model
192 .verification
193 .iter()
194 .map(|check| &check.fact)
195 .chain(model.failures.iter())
196 .map(|entry| &entry.evidence)
197 .collect();
198 let omitted = model
199 .actions
200 .iter()
201 .filter(|entry| !categorized_evidence.contains(&entry.evidence))
202 .count()
203 + model.goals.len().saturating_sub(1)
204 + model.plan_evolution.len()
205 + model.failures.len().saturating_sub(1)
206 + model.changes.len().saturating_sub(2)
207 + model.decisions.len().saturating_sub(1)
208 + model.verification.len().saturating_sub(2)
209 + model.review_priorities.len().saturating_sub(2);
210 if omitted > 0 {
211 lines.push(format!(
212 "{omitted} {} omitted; use /explain --details.",
213 if omitted == 1 { "entry" } else { "entries" }
214 ));
215 }
216 if !c.warnings.is_empty() {
217 lines.push(format!("Unavailable: {}", c.warnings.iter().map(|w| short(w)).collect::<Vec<_>>().join("; ")));
218 }
219 debug_assert!(lines.len() <= 20);
220 lines.join("\n")
221}
222
223pub fn render_diagram(model: &ExplanationModel, width: usize) -> String {
225 let width = width.max(8);
226 let mut out = format!("Recorded execution: {}\n", short(&model.status));
227 for (i, action) in model.actions.iter().enumerate() {
228 let prefix = format!("{} {} ", i + 1, if action.status.contains("fail") { "x" } else { "->" });
229 let label = format!("{} [{}]", line(&action.label), action.status);
230 let _ = writeln!(out, "{prefix}{label}");
231 }
232 let labels = graph_labels(model);
233 for edge in model.graph.iter().filter(|e| e.relation != "recorded next") {
234 let from = labels.get(&edge.from).map_or(edge.from.as_str(), String::as_str);
235 let to = labels.get(&edge.to).map_or(edge.to.as_str(), String::as_str);
236 let _ = writeln!(out, "{} -> {} ({})", short(from), short(to), edge.relation);
237 }
238 out.push_str("Timing and ancestry are shown only when recorded.\n");
239 let mut wrapped = String::new();
240 for row in out.lines() {
241 let mut remaining = row;
242 while !remaining.is_empty() {
243 let chunk = vtcode_commons::preview::truncate_to_display_width(remaining, width);
244 wrapped.push_str(chunk);
245 wrapped.push('\n');
246 remaining = remaining.get(chunk.len()..).unwrap_or_default();
247 }
248 }
249 wrapped
250}
251
252pub fn render_html(model: &ExplanationModel, evidence: &[EvidencePage]) -> String {
254 render_html_with_workspace(model, evidence, None)
255}
256
257pub fn render_html_with_workspace(
259 model: &ExplanationModel,
260 evidence: &[EvidencePage],
261 workspace: Option<&WorkspaceDiffSnapshot>,
262) -> String {
263 fn escape(s: &str) -> String {
264 s.replace('&', "&")
265 .replace('<', "<")
266 .replace('>', ">")
267 .replace('"', """)
268 .replace('\'', "'")
269 }
270 fn fact(out: &mut String, entry: &ExplanationEntry, available: &std::collections::BTreeSet<&EvidenceRef>) {
271 let _ = write!(out, "<p>{} — {}", escape(&entry.label), escape(&entry.status));
272 if let Some(timestamp) = &entry.timestamp {
273 let _ = write!(out, " <time>{}</time>", escape(timestamp));
274 }
275 if available.contains(&entry.evidence) {
276 let _ = write!(out, " <a href=\"#evidence-{}\">evidence</a>", entry.evidence.offset);
277 } else {
278 out.push_str(" (evidence unavailable in this report)");
279 }
280 out.push_str("</p>");
281 }
282 let mut out = String::from(
283 "<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'\"><title>VT Code execution explanation</title><style>body{font:16px system-ui;margin:2rem auto;max-width:1000px;padding:0 1rem;color:#17212b;background:#fff}pre{white-space:pre-wrap;overflow-wrap:anywhere}details{border:1px solid #687686;padding:.7rem;margin:.6rem 0}a{color:#004d99}svg{max-width:100%;height:auto}text{font:14px monospace;fill:#17212b}</style><body><h1>Execution explanation</h1>",
284 );
285 let mut summary = escape(&render_summary_with_labels(model, short));
286 for page in evidence {
287 summary = summary.replace(
288 &escape(&evidence_link(&page.reference)),
289 &format!("<a href=\"#evidence-{}\">evidence</a>", page.reference.offset),
290 );
291 }
292 let _ = write!(
293 out,
294 "<p>Scope: {:?}. Outcome: {}. Revision: {}</p><pre>{}</pre>",
295 model.scope,
296 escape(&model.status),
297 escape(&model.revision),
298 summary
299 );
300 let available = evidence.iter().map(|page| &page.reference).collect();
301 out.push_str("<details><summary>Current workspace state</summary>");
302 if let Some(workspace) = workspace {
303 let _ = write!(out, "<p>Captured at {}. {}</p>", escape(&workspace.captured_at), escape(&workspace.note));
304 if let Some(text) = &workspace.text {
305 let _ = write!(out, "<pre>{}</pre>", escape(text));
306 if text.is_empty() {
307 out.push_str("<p>No tracked Git changes against HEAD at capture time.</p>");
308 }
309 }
310 if workspace.truncated {
311 out.push_str("<p>Current diff truncated at the snapshot limit.</p>");
312 }
313 } else {
314 out.push_str("<p>Current workspace state was not captured in this report.</p>");
315 }
316 out.push_str("</details>");
317 for (name, entries) in [
318 ("Goals", &model.goals),
319 ("Captured changes", &model.changes),
320 ("Timeline", &model.actions),
321 ("Failures", &model.failures),
322 ("Plan evolution", &model.plan_evolution),
323 ] {
324 let _ = write!(out, "<details><summary>{name} ({})</summary>", entries.len());
325 for entry in entries {
326 fact(&mut out, entry, &available);
327 }
328 out.push_str("</details>");
329 }
330 out.push_str("<details><summary>Decisions</summary>");
331 if model.decisions.is_empty() {
332 out.push_str("<p>Public rationale unavailable.</p>");
333 }
334 for decision in &model.decisions {
335 fact(&mut out, &decision.fact, &available);
336 let _ = write!(out, "<p>Agent-reported rationale: {}</p>", escape(&decision.rationale));
337 for alternative in &decision.alternatives {
338 let _ = write!(out, "<p>Alternative: {}</p>", escape(alternative));
339 }
340 }
341 out.push_str("</details><details><summary>Verification</summary>");
342 for check in &model.verification {
343 fact(&mut out, &check.fact, &available);
344 let _ = write!(out, "<p>Exit code: {:?}. Fresh: {}.</p>", check.exit_code, check.fresh);
345 }
346 out.push_str("</details><details><summary>Review first</summary><p>Review signals are not proven bugs or coverage assessments.</p>");
347 for signal in &model.review_priorities {
348 fact(&mut out, &signal.fact, &available);
349 let _ = write!(out, "<p>{:?}: {}</p>", signal.priority, escape(&signal.reason));
350 }
351 out.push_str("</details><details><summary>Agent tree</summary>");
352 let mut seen = std::collections::BTreeSet::new();
353 for entry in model.goals.iter().chain(&model.actions) {
354 if let Some(actor) = &entry.actor_id
355 && seen.insert((entry.task_id.as_ref(), actor))
356 {
357 let _ = write!(
358 out,
359 "<p>{} → {}</p>",
360 escape(entry.parent_actor_id.as_deref().unwrap_or("Parent unavailable")),
361 escape(actor)
362 );
363 fact(&mut out, entry, &available);
364 }
365 }
366 if seen.is_empty() {
367 out.push_str("<p>Ancestry unavailable.</p>");
368 }
369 out.push_str("</details><details><summary>Usage and timing</summary>");
370 if let Some(usage) = &model.usage {
371 let _ = write!(
372 out,
373 "<p>{} input; {} cached; {} cache creation; {} output tokens.</p>",
374 usage.input_tokens, usage.cached_input_tokens, usage.cache_creation_tokens, usage.output_tokens
375 );
376 } else {
377 out.push_str("<p>Usage unavailable.</p>");
378 }
379 if let Some(cost) = &model.cost_usd {
380 let _ = write!(out, "<p>Recorded cost: ${cost}.</p>");
381 } else {
382 out.push_str("<p>Cost unavailable for this scope.</p>");
383 }
384 for entry in &model.token_breakdowns {
385 fact(&mut out, &entry.fact, &available);
386 if let Ok(json) = serde_json::to_string_pretty(&entry.breakdown) {
387 let _ = write!(out, "<pre>{}</pre>", escape(&json));
388 }
389 }
390 out.push_str("</details>");
391 out.push_str("<h2>Recorded execution</h2>");
392 let height = model.actions.len().saturating_mul(36).saturating_add(12);
393 let _ = write!(out, "<svg role=\"img\" aria-label=\"Recorded execution order\" viewBox=\"0 0 960 {height}\">");
394 for (i, a) in model.actions.iter().enumerate() {
395 let y = i.saturating_mul(36) + 24;
396 let _ = write!(
397 out,
398 "<text x=\"8\" y=\"{y}\">{} → {} [{}]</text>",
399 i + 1,
400 escape(&short(&a.label)),
401 escape(&a.status)
402 );
403 }
404 out.push_str("</svg><h2>Recorded action and file graph</h2>");
405 let labels = graph_labels(model);
406 let height = model.graph.len().saturating_mul(36).saturating_add(12);
407 let _ = write!(
408 out,
409 "<svg role=\"img\" aria-label=\"Recorded action and file relationships\" viewBox=\"0 0 960 {height}\">"
410 );
411 for (index, edge) in model.graph.iter().enumerate() {
412 let y = index.saturating_mul(36) + 24;
413 let from = labels.get(&edge.from).map_or(edge.from.as_str(), String::as_str);
414 let to = labels.get(&edge.to).map_or(edge.to.as_str(), String::as_str);
415 let _ = write!(
416 out,
417 "<text x=\"8\" y=\"{y}\">{} → {} ({})</text>",
418 escape(from),
419 escape(to),
420 escape(&edge.relation)
421 );
422 }
423 out.push_str("</svg><h2>Evidence</h2>");
424 for page in evidence {
425 let _ = write!(
426 out,
427 "<details id=\"evidence-{}\"><summary>{}</summary><pre>{}</pre>{}</details>",
428 page.reference.offset,
429 escape(page.reference.item_id.as_deref().unwrap_or("Event")),
430 escape(&page.text),
431 if page.next_offset.is_some() {
432 "<p>Evidence truncated in this report.</p>"
433 } else {
434 ""
435 }
436 );
437 }
438 out.push_str("<h2>Projected facts</h2><pre>");
439 match serde_json::to_string_pretty(model) {
440 Ok(json) => out.push_str(&escape(&json)),
441 Err(_) => out.push_str("Projection serialization unavailable."),
442 }
443 out.push_str("</pre></body></html>");
444 out
445}