use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub struct ContextRadar {
pub events: Vec<RadarEvent>,
pub rules_tokens: RulesTokens,
pub window_size: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadarEvent {
pub ts: u64,
pub event_type: String,
pub tokens: usize,
#[serde(default)]
pub tool_name: Option<String>,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub conversation_id: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct RulesTokens {
pub files: Vec<(String, usize)>,
pub total: usize,
}
#[derive(Debug, Serialize)]
pub struct BudgetBreakdown {
pub window_size: usize,
pub system_prompt_tokens: usize,
pub user_message_tokens: usize,
pub agent_response_tokens: usize,
pub lean_ctx_tool_tokens: usize,
pub other_mcp_tokens: usize,
pub native_read_tokens: usize,
pub shell_tokens: usize,
pub thinking_tokens: usize,
pub tracked_total: usize,
pub available: usize,
pub compaction_count: usize,
pub session_total_tokens: usize,
pub session_user_tokens: usize,
pub session_agent_tokens: usize,
pub session_lctx_tokens: usize,
pub session_mcp_tokens: usize,
pub session_native_tokens: usize,
pub session_shell_tokens: usize,
pub session_thinking_tokens: usize,
pub source: String,
}
impl ContextRadar {
pub fn new(window_size: usize) -> Self {
Self {
events: Vec::new(),
rules_tokens: RulesTokens::default(),
window_size,
}
}
pub fn load(data_dir: &Path, window_size: usize) -> Self {
let mut radar = Self::new(window_size);
radar.load_events(data_dir);
radar.scan_rules();
radar
}
fn load_events(&mut self, data_dir: &Path) {
let mut all: Vec<RadarEvent> = Vec::new();
let prev_path = data_dir.join("context_radar.prev.jsonl");
if let Ok(content) = std::fs::read_to_string(&prev_path) {
for line in content.lines() {
if let Ok(ev) = serde_json::from_str::<RadarEvent>(line) {
all.push(ev);
}
}
}
let radar_path = data_dir.join("context_radar.jsonl");
if let Ok(content) = std::fs::read_to_string(&radar_path) {
for line in content.lines() {
if let Ok(ev) = serde_json::from_str::<RadarEvent>(line) {
all.push(ev);
}
}
}
const MAX_EVENTS: usize = 50_000;
if all.len() > MAX_EVENTS {
self.events = all[all.len() - MAX_EVENTS..].to_vec();
} else {
self.events = all;
}
}
pub fn scan_rules(&mut self) {
let Some(home) = crate::core::home::resolve_home_dir() else {
return;
};
let cwd = std::env::current_dir().unwrap_or_default();
let mut files: Vec<(String, usize)> = Vec::new();
let paths_to_scan: Vec<PathBuf> = vec![
cwd.join(".cursorrules"),
cwd.join("AGENTS.md"),
cwd.join("CLAUDE.md"),
cwd.join("LEAN-CTX.md"),
home.join(".cursor").join("rules"),
home.join(".cursorrules"),
cwd.join(".cursor").join("rules"),
];
for path in &paths_to_scan {
if path.is_file() {
if Self::is_rules_file(path) {
if let Ok(content) = std::fs::read_to_string(path) {
let tokens = content.len() / 4;
if tokens > 0 {
files.push((path.display().to_string(), tokens));
}
}
}
} else if path.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && Self::is_rules_file(&p) {
if let Ok(content) = std::fs::read_to_string(&p) {
let tokens = content.len() / 4;
if tokens > 0 {
files.push((p.display().to_string(), tokens));
}
}
}
}
}
}
}
let total = files.iter().map(|(_, t)| *t).sum();
self.rules_tokens = RulesTokens { files, total };
}
fn is_rules_file(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name.starts_with('.') && name != ".cursorrules" {
return false;
}
if name.contains(".bak") || name.contains(".tmp") || name.contains(".swp") {
return false;
}
if name == ".cursorrules"
|| name == "AGENTS.md"
|| name == "CLAUDE.md"
|| name == "LEAN-CTX.md"
{
return true;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(ext, "md" | "mdc" | "markdown" | "txt")
}
pub fn budget_breakdown(&self) -> BudgetBreakdown {
let mut compaction_count = 0;
let mut last_compaction_idx: Option<usize> = None;
for (i, event) in self.events.iter().enumerate() {
if event.event_type == "compaction" {
compaction_count += 1;
last_compaction_idx = Some(i);
}
}
let current_window_start = last_compaction_idx.map_or(0, |i| i + 1);
let current_events = &self.events[current_window_start..];
let (mut user_cur, mut agent_cur, mut lctx_cur, mut mcp_cur) = (0, 0, 0, 0);
let (mut native_cur, mut shell_cur, mut thinking_cur) = (0, 0, 0);
let (mut user_all, mut agent_all, mut lctx_all, mut mcp_all) = (0, 0, 0, 0);
let (mut native_all, mut shell_all, mut thinking_all) = (0, 0, 0);
for event in &self.events {
Self::classify_event(
event,
&mut user_all,
&mut agent_all,
&mut lctx_all,
&mut mcp_all,
&mut native_all,
&mut shell_all,
&mut thinking_all,
);
}
for event in current_events {
Self::classify_event(
event,
&mut user_cur,
&mut agent_cur,
&mut lctx_cur,
&mut mcp_cur,
&mut native_cur,
&mut shell_cur,
&mut thinking_cur,
);
}
let system_prompt_tokens = self.rules_tokens.total;
let tracked_total = system_prompt_tokens
+ user_cur
+ agent_cur
+ lctx_cur
+ mcp_cur
+ native_cur
+ shell_cur;
let available = self.window_size.saturating_sub(tracked_total);
let session_total = system_prompt_tokens
+ user_all
+ agent_all
+ lctx_all
+ mcp_all
+ native_all
+ shell_all;
BudgetBreakdown {
window_size: self.window_size,
system_prompt_tokens,
user_message_tokens: user_cur,
agent_response_tokens: agent_cur,
lean_ctx_tool_tokens: lctx_cur,
other_mcp_tokens: mcp_cur,
native_read_tokens: native_cur,
shell_tokens: shell_cur,
thinking_tokens: thinking_cur,
tracked_total,
available,
compaction_count,
session_total_tokens: session_total,
session_user_tokens: user_all,
session_agent_tokens: agent_all,
session_lctx_tokens: lctx_all,
session_mcp_tokens: mcp_all,
session_native_tokens: native_all,
session_shell_tokens: shell_all,
session_thinking_tokens: thinking_all,
source: "hooks + rules-scan".to_string(),
}
}
fn classify_event(
event: &RadarEvent,
user: &mut usize,
agent: &mut usize,
lctx: &mut usize,
mcp: &mut usize,
native: &mut usize,
shell: &mut usize,
thinking: &mut usize,
) {
match event.event_type.as_str() {
"user_message" => *user += event.tokens,
"agent_response" => *agent += event.tokens,
"mcp_call" => {
let is_leanctx = event
.detail
.as_deref()
.is_some_and(|d| d.contains("lean-ctx"))
|| event
.tool_name
.as_deref()
.is_some_and(|t| t.starts_with("ctx_"));
if is_leanctx {
*lctx += event.tokens;
} else {
*mcp += event.tokens;
}
}
"native_tool" | "file_read" => *native += event.tokens,
"shell" => *shell += event.tokens,
"thinking" => *thinking += event.tokens,
_ => {}
}
}
pub fn format_display(&self) -> String {
let b = self.budget_breakdown();
let pct = |tokens: usize| -> f64 {
if b.window_size == 0 {
0.0
} else {
(tokens as f64 / b.window_size as f64 * 100.0).min(100.0)
}
};
let bar = |tokens: usize| -> String {
let width = (pct(tokens) / 2.0).min(40.0) as usize;
"â–ˆ".repeat(width)
};
let mut out = String::new();
out.push_str(&format!(
"CONTEXT RADAR — Current Window ({:.0}k)\n",
b.window_size as f64 / 1000.0
));
if b.compaction_count > 0 {
out.push_str(&format!(
" (after {} compaction(s) — showing current window only)\n",
b.compaction_count
));
}
out.push_str(&format!(
" System Prompt (est.): {:>8} tok {:>5.1}% {}\n",
fmt_num(b.system_prompt_tokens),
pct(b.system_prompt_tokens),
bar(b.system_prompt_tokens)
));
out.push_str(&format!(
" User Messages: {:>8} tok {:>5.1}% {}\n",
fmt_num(b.user_message_tokens),
pct(b.user_message_tokens),
bar(b.user_message_tokens)
));
out.push_str(&format!(
" Agent Responses: {:>8} tok {:>5.1}% {}\n",
fmt_num(b.agent_response_tokens),
pct(b.agent_response_tokens),
bar(b.agent_response_tokens)
));
out.push_str(&format!(
" lean-ctx Tools: {:>8} tok {:>5.1}% {}\n",
fmt_num(b.lean_ctx_tool_tokens),
pct(b.lean_ctx_tool_tokens),
bar(b.lean_ctx_tool_tokens)
));
out.push_str(&format!(
" Other MCP: {:>8} tok {:>5.1}% {}\n",
fmt_num(b.other_mcp_tokens),
pct(b.other_mcp_tokens),
bar(b.other_mcp_tokens)
));
out.push_str(&format!(
" Native Reads: {:>8} tok {:>5.1}% {}\n",
fmt_num(b.native_read_tokens),
pct(b.native_read_tokens),
bar(b.native_read_tokens)
));
out.push_str(&format!(
" Shell Output: {:>8} tok {:>5.1}% {}\n",
fmt_num(b.shell_tokens),
pct(b.shell_tokens),
bar(b.shell_tokens)
));
out.push_str(" ──────────────────────────────────────────\n");
out.push_str(&format!(
" TRACKED: {:>8} tok {:>5.1}%\n",
fmt_num(b.tracked_total),
pct(b.tracked_total)
));
out.push_str(&format!(
" Available: {:>8} tok {:>5.1}%\n",
fmt_num(b.available),
pct(b.available)
));
if b.thinking_tokens > 0 {
out.push_str(&format!(
" Thinking (not in window): {:>5} tok\n",
fmt_num(b.thinking_tokens)
));
}
if b.session_total_tokens > b.tracked_total {
out.push_str(&format!(
"\n SESSION TOTAL: {:>8} tok (across {} compaction(s))\n",
fmt_num(b.session_total_tokens),
b.compaction_count
));
}
out.push_str(&format!(" Source: {}\n", b.source));
out
}
}
fn fmt_num(n: usize) -> String {
if n >= 1000 {
format!("{},{:03}", n / 1000, n % 1000)
} else {
n.to_string()
}
}
pub fn default_window_for_client(client: &str) -> usize {
if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
return window;
}
match client.to_lowercase().as_str() {
"gemini" => 1_000_000,
"windsurf" | "zed" | "copilot" => 128_000,
_ => 200_000,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budget_breakdown_empty() {
let radar = ContextRadar::new(200_000);
let b = radar.budget_breakdown();
assert_eq!(b.window_size, 200_000);
assert_eq!(b.tracked_total, 0);
assert_eq!(b.available, 200_000);
}
fn ev(
ts: u64,
event_type: &str,
tokens: usize,
tool_name: Option<&str>,
detail: Option<&str>,
) -> RadarEvent {
RadarEvent {
ts,
event_type: event_type.to_string(),
tokens,
tool_name: tool_name.map(String::from),
detail: detail.map(String::from),
content: None,
model: None,
conversation_id: None,
}
}
#[test]
fn budget_breakdown_with_events() {
let mut radar = ContextRadar::new(200_000);
radar.events.push(ev(1000, "user_message", 500, None, None));
radar
.events
.push(ev(1001, "agent_response", 2000, None, None));
radar
.events
.push(ev(1002, "shell", 300, None, Some("git status")));
let b = radar.budget_breakdown();
assert_eq!(b.user_message_tokens, 500);
assert_eq!(b.agent_response_tokens, 2000);
assert_eq!(b.shell_tokens, 300);
assert_eq!(b.tracked_total, 2800);
assert_eq!(b.available, 200_000 - 2800);
}
#[test]
fn budget_breakdown_resets_after_compaction() {
let mut radar = ContextRadar::new(100_000);
radar.events.push(ev(1, "user_message", 50_000, None, None));
radar.events.push(ev(2, "compaction", 0, None, None));
radar.events.push(ev(3, "user_message", 10_000, None, None));
let b = radar.budget_breakdown();
assert_eq!(
b.user_message_tokens, 10_000,
"only counts since compaction"
);
assert_eq!(b.available, 90_000);
assert_eq!(b.compaction_count, 1);
assert_eq!(b.session_user_tokens, 60_000, "session total includes all");
}
#[test]
fn format_display_not_empty() {
let radar = ContextRadar::new(200_000);
let display = radar.format_display();
assert!(display.contains("CONTEXT RADAR"));
assert!(display.contains("200k"));
}
#[test]
fn default_window_sizes() {
if crate::hook_handlers::load_detected_model().is_some() {
let w = default_window_for_client("cursor");
assert!(
(128_000..=2_000_000).contains(&w),
"window {w} out of range"
);
} else {
assert_eq!(default_window_for_client("cursor"), 200_000);
assert_eq!(default_window_for_client("gemini"), 1_000_000);
assert_eq!(default_window_for_client("windsurf"), 128_000);
}
}
}