1use 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}