Skip to main content

zeph_tui/widgets/
memory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use ratatui::Frame;
5use ratatui::layout::Rect;
6use ratatui::style::{Color, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, Borders, Paragraph};
9
10use crate::metrics::{MetricsSnapshot, ProbeCategory, ProbeVerdict};
11use crate::theme::Theme;
12
13fn cat_label(cat: ProbeCategory) -> &'static str {
14    match cat {
15        ProbeCategory::Recall => "Rec",
16        ProbeCategory::Artifact => "Art",
17        ProbeCategory::Continuation => "Con",
18        ProbeCategory::Decision => "Dec",
19    }
20}
21
22fn render_probe_last_line<'a>(metrics: &'a MetricsSnapshot, lines: &mut Vec<Line<'a>>) {
23    let Some(verdict) = &metrics.last_probe_verdict else {
24        return;
25    };
26    let (label, color) = match verdict {
27        ProbeVerdict::Pass => ("Pass", Color::Green),
28        ProbeVerdict::SoftFail => ("SoftFail", Color::Yellow),
29        ProbeVerdict::HardFail => ("HardFail", Color::Red),
30        ProbeVerdict::Error => ("Error", Color::Gray),
31    };
32    let score_str = metrics
33        .last_probe_score
34        .map_or_else(String::new, |sc| format!(" ({sc:.2})"));
35
36    if let Some(ref cat_scores) = metrics.last_probe_category_scores {
37        let threshold = metrics.compaction_probe_threshold;
38        let hard_fail = metrics.compaction_probe_hard_fail_threshold;
39        let cat_color = |score: f32| -> Color {
40            if score >= threshold {
41                Color::Green
42            } else if score >= hard_fail {
43                Color::Yellow
44            } else {
45                Color::Red
46            }
47        };
48
49        let mut spans: Vec<Span<'_>> = vec![
50            Span::raw("  Last: "),
51            Span::styled(format!("{label}{score_str}"), Style::default().fg(color)),
52        ];
53        for cat in [
54            ProbeCategory::Recall,
55            ProbeCategory::Artifact,
56            ProbeCategory::Continuation,
57            ProbeCategory::Decision,
58        ] {
59            spans.push(Span::raw(" "));
60            let lbl = cat_label(cat);
61            if let Some(cs) = cat_scores.iter().find(|cs| cs.category == cat) {
62                spans.push(Span::styled(
63                    format!("{lbl}:{:.2}", cs.score),
64                    Style::default().fg(cat_color(cs.score)),
65                ));
66            } else {
67                spans.push(Span::raw(format!("{lbl}:--")));
68            }
69        }
70        lines.push(Line::from(spans));
71    } else {
72        lines.push(Line::from(vec![
73            Span::raw("  Last: "),
74            Span::styled(format!("{label}{score_str}"), Style::default().fg(color)),
75        ]));
76    }
77}
78
79pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect) {
80    let theme = Theme::default();
81
82    let mut mem_lines = vec![Line::from(format!(
83        "  SQLite: {} msgs",
84        metrics.sqlite_message_count
85    ))];
86    if metrics.qdrant_available {
87        mem_lines.push(Line::from(format!(
88            "  Vector: {} (connected)",
89            metrics.vector_backend
90        )));
91    } else if !metrics.vector_backend.is_empty() {
92        mem_lines.push(Line::from(format!(
93            "  Vector: {} (offline)",
94            metrics.vector_backend
95        )));
96    }
97    mem_lines.push(Line::from(format!(
98        "  Conv ID: {}",
99        metrics
100            .sqlite_conversation_id
101            .map_or_else(|| "---".to_string(), |id| id.to_string())
102    )));
103    mem_lines.push(Line::from(format!(
104        "  Embeddings: {}",
105        metrics.embeddings_generated
106    )));
107    {
108        mem_lines.push(Line::from(format!(
109            "  Graph: {} entities, {} edges, {} communities",
110            metrics.graph_entities_total,
111            metrics.graph_edges_total,
112            metrics.graph_communities_total,
113        )));
114        mem_lines.push(Line::from(format!(
115            "  Graph extractions: {} ok, {} failed",
116            metrics.graph_extraction_count, metrics.graph_extraction_failures,
117        )));
118    }
119    let total_probes = metrics.compaction_probe_passes
120        + metrics.compaction_probe_soft_failures
121        + metrics.compaction_probe_failures
122        + metrics.compaction_probe_errors;
123
124    #[allow(
125        clippy::cast_precision_loss,
126        clippy::cast_possible_truncation,
127        clippy::cast_sign_loss
128    )]
129    if total_probes > 0 {
130        let pct = |n: u64| -> u64 { (n as f64 / total_probes as f64 * 100.0).round() as u64 };
131        let p = pct(metrics.compaction_probe_passes);
132        let s = pct(metrics.compaction_probe_soft_failures);
133        let h = pct(metrics.compaction_probe_failures);
134        let e = pct(metrics.compaction_probe_errors);
135        mem_lines.push(Line::from(format!("  Probe: P {p}% S {s}% H {h}% E {e}%")));
136
137        render_probe_last_line(metrics, &mut mem_lines);
138    }
139
140    if metrics.semantic_fact_count > 0 {
141        mem_lines.push(Line::from(format!(
142            "  Semantic facts: {}",
143            metrics.semantic_fact_count,
144        )));
145    }
146    if metrics.guidelines_version > 0 {
147        mem_lines.push(Line::from(format!(
148            "  Guidelines: v{} ({})",
149            metrics.guidelines_version, metrics.guidelines_updated_at,
150        )));
151    }
152    let memory = Paragraph::new(mem_lines).block(
153        Block::default()
154            .borders(Borders::ALL)
155            .border_style(theme.panel_border)
156            .title(" Memory "),
157    );
158    frame.render_widget(memory, area);
159}
160
161#[cfg(test)]
162mod tests {
163    use insta::assert_snapshot;
164
165    use crate::metrics::{CategoryScore, MetricsSnapshot, ProbeCategory, ProbeVerdict};
166    use crate::test_utils::render_to_string;
167
168    #[test]
169    fn memory_with_stats() {
170        let metrics = MetricsSnapshot {
171            sqlite_message_count: 42,
172            qdrant_available: true,
173            vector_backend: "qdrant".into(),
174            embeddings_generated: 10,
175            ..MetricsSnapshot::default()
176        };
177
178        let output = render_to_string(30, 8, |frame, area| {
179            super::render(&metrics, frame, area);
180        });
181        assert_snapshot!(output);
182    }
183
184    #[test]
185    fn memory_with_guidelines() {
186        let metrics = MetricsSnapshot {
187            sqlite_message_count: 10,
188            embeddings_generated: 0,
189            guidelines_version: 3,
190            guidelines_updated_at: "2026-03-15T17:00:00.000Z".into(),
191            ..MetricsSnapshot::default()
192        };
193
194        let output = render_to_string(50, 10, |frame, area| {
195            super::render(&metrics, frame, area);
196        });
197        assert_snapshot!(output);
198    }
199
200    #[test]
201    fn probe_lines_visible_when_probes_ran() {
202        let metrics = MetricsSnapshot {
203            sqlite_message_count: 5,
204            compaction_probe_passes: 87,
205            compaction_probe_soft_failures: 10,
206            compaction_probe_failures: 2,
207            compaction_probe_errors: 1,
208            last_probe_verdict: Some(ProbeVerdict::Pass),
209            last_probe_score: Some(0.91),
210            ..MetricsSnapshot::default()
211        };
212
213        let output = render_to_string(50, 12, |frame, area| {
214            super::render(&metrics, frame, area);
215        });
216        assert_snapshot!(output);
217    }
218
219    #[test]
220    fn probe_lines_with_category_scores() {
221        let metrics = MetricsSnapshot {
222            sqlite_message_count: 5,
223            compaction_probe_passes: 1,
224            last_probe_verdict: Some(ProbeVerdict::Pass),
225            last_probe_score: Some(0.72),
226            compaction_probe_threshold: 0.6,
227            compaction_probe_hard_fail_threshold: 0.35,
228            last_probe_category_scores: Some(vec![
229                CategoryScore {
230                    category: ProbeCategory::Recall,
231                    score: 0.90,
232                    probes_run: 2,
233                },
234                CategoryScore {
235                    category: ProbeCategory::Artifact,
236                    score: 0.50,
237                    probes_run: 1,
238                },
239                CategoryScore {
240                    category: ProbeCategory::Continuation,
241                    score: 0.80,
242                    probes_run: 1,
243                },
244                CategoryScore {
245                    category: ProbeCategory::Decision,
246                    score: 0.60,
247                    probes_run: 1,
248                },
249            ]),
250            ..MetricsSnapshot::default()
251        };
252
253        let output = render_to_string(70, 14, |frame, area| {
254            super::render(&metrics, frame, area);
255        });
256        assert_snapshot!(output);
257    }
258
259    #[test]
260    fn probe_lines_hidden_when_no_probes() {
261        let metrics = MetricsSnapshot {
262            sqlite_message_count: 5,
263            compaction_probe_passes: 0,
264            compaction_probe_soft_failures: 0,
265            compaction_probe_failures: 0,
266            compaction_probe_errors: 0,
267            last_probe_verdict: None,
268            last_probe_score: None,
269            ..MetricsSnapshot::default()
270        };
271
272        let output = render_to_string(50, 10, |frame, area| {
273            super::render(&metrics, frame, area);
274        });
275        assert_snapshot!(output);
276    }
277
278    #[test]
279    fn probe_error_verdict_shows_no_score() {
280        let metrics = MetricsSnapshot {
281            sqlite_message_count: 5,
282            compaction_probe_passes: 1,
283            compaction_probe_errors: 1,
284            last_probe_verdict: Some(ProbeVerdict::Error),
285            last_probe_score: None,
286            ..MetricsSnapshot::default()
287        };
288
289        let output = render_to_string(50, 12, |frame, area| {
290            super::render(&metrics, frame, area);
291        });
292        assert_snapshot!(output);
293    }
294}