Skip to main content

vtcode_memory/explanation/
render.rs

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
58/// Full public entries with canonical source links.
59pub 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
112/// Five sections within twenty logical lines, including mandatory diagnostics.
113pub 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
223/// Width-aware recorded order, failures, and action-to-file relationships.
224pub 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
252/// Self-contained offline report; evidence is embedded and all text is escaped.
253pub fn render_html(model: &ExplanationModel, evidence: &[EvidencePage]) -> String {
254    render_html_with_workspace(model, evidence, None)
255}
256
257/// Offline report with separately captured, unattributed current workspace state.
258pub 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('&', "&amp;")
265            .replace('<', "&lt;")
266            .replace('>', "&gt;")
267            .replace('"', "&quot;")
268            .replace('\'', "&#39;")
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}