1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5pub struct ContextRadar {
8 pub events: Vec<RadarEvent>,
9 pub rules_tokens: RulesTokens,
10 pub window_size: usize,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct RadarEvent {
15 pub ts: u64,
16 pub event_type: String,
17 pub tokens: usize,
18 #[serde(default)]
19 pub tool_name: Option<String>,
20 #[serde(default)]
21 pub detail: Option<String>,
22}
23
24#[derive(Debug, Default, Clone)]
25pub struct RulesTokens {
26 pub files: Vec<(String, usize)>,
27 pub total: usize,
28}
29
30#[derive(Debug, Serialize)]
31pub struct BudgetBreakdown {
32 pub window_size: usize,
33 pub system_prompt_tokens: usize,
34 pub user_message_tokens: usize,
35 pub agent_response_tokens: usize,
36 pub lean_ctx_tool_tokens: usize,
37 pub other_mcp_tokens: usize,
38 pub native_read_tokens: usize,
39 pub shell_tokens: usize,
40 pub thinking_tokens: usize,
41 pub tracked_total: usize,
42 pub available: usize,
43 pub compaction_count: usize,
44 pub session_total_tokens: usize,
45 pub session_user_tokens: usize,
46 pub session_agent_tokens: usize,
47 pub session_lctx_tokens: usize,
48 pub session_mcp_tokens: usize,
49 pub session_native_tokens: usize,
50 pub session_shell_tokens: usize,
51 pub session_thinking_tokens: usize,
52 pub source: String,
53}
54
55impl ContextRadar {
56 pub fn new(window_size: usize) -> Self {
57 Self {
58 events: Vec::new(),
59 rules_tokens: RulesTokens::default(),
60 window_size,
61 }
62 }
63
64 pub fn load(data_dir: &Path, window_size: usize) -> Self {
65 let mut radar = Self::new(window_size);
66 radar.load_events(data_dir);
67 radar.scan_rules();
68 radar
69 }
70
71 fn load_events(&mut self, data_dir: &Path) {
72 let radar_path = data_dir.join("context_radar.jsonl");
73 let Ok(content) = std::fs::read_to_string(&radar_path) else {
74 return;
75 };
76
77 const MAX_EVENTS: usize = 50_000;
78 let all: Vec<RadarEvent> = content
79 .lines()
80 .filter_map(|line| serde_json::from_str::<RadarEvent>(line).ok())
81 .collect();
82 if all.len() > MAX_EVENTS {
83 self.events = all[all.len() - MAX_EVENTS..].to_vec();
84 } else {
85 self.events = all;
86 }
87 }
88
89 pub fn scan_rules(&mut self) {
90 let Some(home) = crate::core::home::resolve_home_dir() else {
91 return;
92 };
93
94 let cwd = std::env::current_dir().unwrap_or_default();
95 let mut files: Vec<(String, usize)> = Vec::new();
96
97 let paths_to_scan: Vec<PathBuf> = vec![
98 cwd.join(".cursorrules"),
99 cwd.join("AGENTS.md"),
100 cwd.join("CLAUDE.md"),
101 cwd.join("LEAN-CTX.md"),
102 home.join(".cursor").join("rules"),
103 home.join(".cursorrules"),
104 cwd.join(".cursor").join("rules"),
105 ];
106
107 for path in &paths_to_scan {
108 if path.is_file() {
109 if let Ok(content) = std::fs::read_to_string(path) {
110 let tokens = content.len() / 4;
111 if tokens > 0 {
112 files.push((path.display().to_string(), tokens));
113 }
114 }
115 } else if path.is_dir() {
116 if let Ok(entries) = std::fs::read_dir(path) {
117 for entry in entries.flatten() {
118 let p = entry.path();
119 if p.is_file() {
120 if let Ok(content) = std::fs::read_to_string(&p) {
121 let tokens = content.len() / 4;
122 if tokens > 0 {
123 files.push((p.display().to_string(), tokens));
124 }
125 }
126 }
127 }
128 }
129 }
130 }
131
132 let total = files.iter().map(|(_, t)| *t).sum();
133 self.rules_tokens = RulesTokens { files, total };
134 }
135
136 pub fn budget_breakdown(&self) -> BudgetBreakdown {
137 let mut compaction_count = 0;
138 let mut last_compaction_idx: Option<usize> = None;
139
140 for (i, event) in self.events.iter().enumerate() {
141 if event.event_type == "compaction" {
142 compaction_count += 1;
143 last_compaction_idx = Some(i);
144 }
145 }
146
147 let current_window_start = last_compaction_idx.map_or(0, |i| i + 1);
148 let current_events = &self.events[current_window_start..];
149
150 let (mut user_cur, mut agent_cur, mut lctx_cur, mut mcp_cur) = (0, 0, 0, 0);
151 let (mut native_cur, mut shell_cur, mut thinking_cur) = (0, 0, 0);
152 let (mut user_all, mut agent_all, mut lctx_all, mut mcp_all) = (0, 0, 0, 0);
153 let (mut native_all, mut shell_all, mut thinking_all) = (0, 0, 0);
154
155 for event in &self.events {
156 Self::classify_event(
157 event,
158 &mut user_all,
159 &mut agent_all,
160 &mut lctx_all,
161 &mut mcp_all,
162 &mut native_all,
163 &mut shell_all,
164 &mut thinking_all,
165 );
166 }
167 for event in current_events {
168 Self::classify_event(
169 event,
170 &mut user_cur,
171 &mut agent_cur,
172 &mut lctx_cur,
173 &mut mcp_cur,
174 &mut native_cur,
175 &mut shell_cur,
176 &mut thinking_cur,
177 );
178 }
179
180 let system_prompt_tokens = self.rules_tokens.total;
181 let tracked_total = system_prompt_tokens
182 + user_cur
183 + agent_cur
184 + lctx_cur
185 + mcp_cur
186 + native_cur
187 + shell_cur;
188 let available = self.window_size.saturating_sub(tracked_total);
189
190 let session_total = system_prompt_tokens
191 + user_all
192 + agent_all
193 + lctx_all
194 + mcp_all
195 + native_all
196 + shell_all;
197
198 BudgetBreakdown {
199 window_size: self.window_size,
200 system_prompt_tokens,
201 user_message_tokens: user_cur,
202 agent_response_tokens: agent_cur,
203 lean_ctx_tool_tokens: lctx_cur,
204 other_mcp_tokens: mcp_cur,
205 native_read_tokens: native_cur,
206 shell_tokens: shell_cur,
207 thinking_tokens: thinking_cur,
208 tracked_total,
209 available,
210 compaction_count,
211 session_total_tokens: session_total,
212 session_user_tokens: user_all,
213 session_agent_tokens: agent_all,
214 session_lctx_tokens: lctx_all,
215 session_mcp_tokens: mcp_all,
216 session_native_tokens: native_all,
217 session_shell_tokens: shell_all,
218 session_thinking_tokens: thinking_all,
219 source: "hooks + rules-scan".to_string(),
220 }
221 }
222
223 fn classify_event(
224 event: &RadarEvent,
225 user: &mut usize,
226 agent: &mut usize,
227 lctx: &mut usize,
228 mcp: &mut usize,
229 native: &mut usize,
230 shell: &mut usize,
231 thinking: &mut usize,
232 ) {
233 match event.event_type.as_str() {
234 "user_message" => *user += event.tokens,
235 "agent_response" => *agent += event.tokens,
236 "mcp_call" => {
237 let is_leanctx = event
238 .detail
239 .as_deref()
240 .is_some_and(|d| d.contains("lean-ctx"))
241 || event
242 .tool_name
243 .as_deref()
244 .is_some_and(|t| t.starts_with("ctx_"));
245 if is_leanctx {
246 *lctx += event.tokens;
247 } else {
248 *mcp += event.tokens;
249 }
250 }
251 "native_tool" | "file_read" => *native += event.tokens,
252 "shell" => *shell += event.tokens,
253 "thinking" => *thinking += event.tokens,
254 _ => {}
255 }
256 }
257
258 pub fn format_display(&self) -> String {
259 let b = self.budget_breakdown();
260 let pct = |tokens: usize| -> f64 {
261 if b.window_size == 0 {
262 0.0
263 } else {
264 (tokens as f64 / b.window_size as f64 * 100.0).min(100.0)
265 }
266 };
267 let bar = |tokens: usize| -> String {
268 let width = (pct(tokens) / 2.0).min(40.0) as usize;
269 "█".repeat(width)
270 };
271
272 let mut out = String::new();
273 out.push_str(&format!(
274 "CONTEXT RADAR — Current Window ({:.0}k)\n",
275 b.window_size as f64 / 1000.0
276 ));
277 if b.compaction_count > 0 {
278 out.push_str(&format!(
279 " (after {} compaction(s) — showing current window only)\n",
280 b.compaction_count
281 ));
282 }
283 out.push_str(&format!(
284 " System Prompt (est.): {:>8} tok {:>5.1}% {}\n",
285 fmt_num(b.system_prompt_tokens),
286 pct(b.system_prompt_tokens),
287 bar(b.system_prompt_tokens)
288 ));
289 out.push_str(&format!(
290 " User Messages: {:>8} tok {:>5.1}% {}\n",
291 fmt_num(b.user_message_tokens),
292 pct(b.user_message_tokens),
293 bar(b.user_message_tokens)
294 ));
295 out.push_str(&format!(
296 " Agent Responses: {:>8} tok {:>5.1}% {}\n",
297 fmt_num(b.agent_response_tokens),
298 pct(b.agent_response_tokens),
299 bar(b.agent_response_tokens)
300 ));
301 out.push_str(&format!(
302 " lean-ctx Tools: {:>8} tok {:>5.1}% {}\n",
303 fmt_num(b.lean_ctx_tool_tokens),
304 pct(b.lean_ctx_tool_tokens),
305 bar(b.lean_ctx_tool_tokens)
306 ));
307 out.push_str(&format!(
308 " Other MCP: {:>8} tok {:>5.1}% {}\n",
309 fmt_num(b.other_mcp_tokens),
310 pct(b.other_mcp_tokens),
311 bar(b.other_mcp_tokens)
312 ));
313 out.push_str(&format!(
314 " Native Reads: {:>8} tok {:>5.1}% {}\n",
315 fmt_num(b.native_read_tokens),
316 pct(b.native_read_tokens),
317 bar(b.native_read_tokens)
318 ));
319 out.push_str(&format!(
320 " Shell Output: {:>8} tok {:>5.1}% {}\n",
321 fmt_num(b.shell_tokens),
322 pct(b.shell_tokens),
323 bar(b.shell_tokens)
324 ));
325 out.push_str(" ──────────────────────────────────────────\n");
326 out.push_str(&format!(
327 " TRACKED: {:>8} tok {:>5.1}%\n",
328 fmt_num(b.tracked_total),
329 pct(b.tracked_total)
330 ));
331 out.push_str(&format!(
332 " Available: {:>8} tok {:>5.1}%\n",
333 fmt_num(b.available),
334 pct(b.available)
335 ));
336 if b.thinking_tokens > 0 {
337 out.push_str(&format!(
338 " Thinking (not in window): {:>5} tok\n",
339 fmt_num(b.thinking_tokens)
340 ));
341 }
342 if b.session_total_tokens > b.tracked_total {
343 out.push_str(&format!(
344 "\n SESSION TOTAL: {:>8} tok (across {} compaction(s))\n",
345 fmt_num(b.session_total_tokens),
346 b.compaction_count
347 ));
348 }
349 out.push_str(&format!(" Source: {}\n", b.source));
350 out
351 }
352}
353
354fn fmt_num(n: usize) -> String {
355 if n >= 1000 {
356 format!("{},{:03}", n / 1000, n % 1000)
357 } else {
358 n.to_string()
359 }
360}
361
362pub fn default_window_for_client(client: &str) -> usize {
364 match client.to_lowercase().as_str() {
365 "gemini" => 1_000_000,
366 "windsurf" | "zed" | "copilot" => 128_000,
367 _ => 200_000,
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn budget_breakdown_empty() {
378 let radar = ContextRadar::new(200_000);
379 let b = radar.budget_breakdown();
380 assert_eq!(b.window_size, 200_000);
381 assert_eq!(b.tracked_total, 0);
382 assert_eq!(b.available, 200_000);
383 }
384
385 #[test]
386 fn budget_breakdown_with_events() {
387 let mut radar = ContextRadar::new(200_000);
388 radar.events.push(RadarEvent {
389 ts: 1000,
390 event_type: "user_message".to_string(),
391 tokens: 500,
392 tool_name: None,
393 detail: None,
394 });
395 radar.events.push(RadarEvent {
396 ts: 1001,
397 event_type: "agent_response".to_string(),
398 tokens: 2000,
399 tool_name: None,
400 detail: None,
401 });
402 radar.events.push(RadarEvent {
403 ts: 1002,
404 event_type: "shell".to_string(),
405 tokens: 300,
406 tool_name: None,
407 detail: Some("git status".to_string()),
408 });
409 let b = radar.budget_breakdown();
410 assert_eq!(b.user_message_tokens, 500);
411 assert_eq!(b.agent_response_tokens, 2000);
412 assert_eq!(b.shell_tokens, 300);
413 assert_eq!(b.tracked_total, 2800);
414 assert_eq!(b.available, 200_000 - 2800);
415 }
416
417 #[test]
418 fn budget_breakdown_resets_after_compaction() {
419 let mut radar = ContextRadar::new(100_000);
420 radar.events.push(RadarEvent {
421 ts: 1,
422 event_type: "user_message".to_string(),
423 tokens: 50_000,
424 tool_name: None,
425 detail: None,
426 });
427 radar.events.push(RadarEvent {
428 ts: 2,
429 event_type: "compaction".to_string(),
430 tokens: 0,
431 tool_name: None,
432 detail: None,
433 });
434 radar.events.push(RadarEvent {
435 ts: 3,
436 event_type: "user_message".to_string(),
437 tokens: 10_000,
438 tool_name: None,
439 detail: None,
440 });
441 let b = radar.budget_breakdown();
442 assert_eq!(
443 b.user_message_tokens, 10_000,
444 "only counts since compaction"
445 );
446 assert_eq!(b.available, 90_000);
447 assert_eq!(b.compaction_count, 1);
448 assert_eq!(b.session_user_tokens, 60_000, "session total includes all");
449 }
450
451 #[test]
452 fn format_display_not_empty() {
453 let radar = ContextRadar::new(200_000);
454 let display = radar.format_display();
455 assert!(display.contains("CONTEXT RADAR"));
456 assert!(display.contains("200k"));
457 }
458
459 #[test]
460 fn default_window_sizes() {
461 assert_eq!(default_window_for_client("cursor"), 200_000);
462 assert_eq!(default_window_for_client("gemini"), 1_000_000);
463 assert_eq!(default_window_for_client("windsurf"), 128_000);
464 }
465}