Skip to main content

lean_ctx/core/value/
format.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared renderers for the value surface. Subtle by construction: one dim
3//! line, no exclamation, nothing when there is nothing measured to say.
4
5use crate::core::security_events::SecurityCounts;
6use crate::core::wrapped::format_tokens;
7
8use super::snapshot::ValueSnapshot;
9
10/// How a channel can render: colour (ANSI dim) and Unicode glyphs are
11/// independent — a Claude status line has colour but no TTY, a legacy Windows
12/// console has a TTY but no reliable glyphs.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct Style {
15    pub color: bool,
16    pub unicode: bool,
17}
18
19impl Style {
20    pub const PLAIN: Self = Self {
21        color: false,
22        unicode: true,
23    };
24
25    /// Colour unless `NO_COLOR` is set; ASCII when `LEAN_CTX_ASCII` is set.
26    /// TTY detection is the caller's job (status lines are not TTYs).
27    pub fn from_env() -> Self {
28        Self {
29            color: std::env::var_os("NO_COLOR").is_none(),
30            unicode: std::env::var_os("LEAN_CTX_ASCII").is_none(),
31        }
32    }
33
34    pub(crate) fn mark(self) -> &'static str {
35        if self.unicode { "◆" } else { "*" }
36    }
37
38    pub(crate) fn shield(self) -> &'static str {
39        if self.unicode { "⛨" } else { "sec" }
40    }
41
42    pub(crate) fn minus(self) -> &'static str {
43        if self.unicode { "−" } else { "-" }
44    }
45
46    pub(crate) fn sep(self) -> &'static str {
47        if self.unicode { " · " } else { " | " }
48    }
49}
50
51/// `◆ lean-ctx −1.2M tok · 41 cached · ⛨ 3` — or `None` when nothing measured
52/// happened yet (a zero would read like lean-ctx is not working).
53pub fn one_line(snap: &ValueSnapshot, style: Style) -> Option<String> {
54    if snap.is_empty() {
55        return None;
56    }
57    let mut parts = Vec::new();
58    if snap.tokens_saved > 0 {
59        parts.push(format!(
60            "{}{} tok",
61            style.minus(),
62            format_tokens(snap.tokens_saved)
63        ));
64    }
65    if snap.cache_hits > 0 {
66        parts.push(format!("{} cached", snap.cache_hits));
67    }
68    let security = snap.security.total();
69    if security > 0 {
70        parts.push(format!("{} {security}", style.shield()));
71    }
72    let body = format!("{} lean-ctx {}", style.mark(), parts.join(style.sep()));
73    Some(if style.color {
74        format!("\x1b[2m{body}\x1b[0m")
75    } else {
76        body
77    })
78}
79
80/// LeanCTX colours (leanctx.com, dark theme) as 24-bit SGR parameters.
81const BRAND_PILL: &str = "1;38;2;17;22;38;48;2;102;140;255"; // canvas on signature
82const BRAND_LABEL: &str = "1;38;2;102;140;255"; // signature
83const BRAND_VALUE: &str = "1;38;2;241;243;255"; // foreground
84const BRAND_DETAIL: &str = "38;2;175;184;208"; // secondary
85const BRAND_RULE: &str = "38;2;53;62;90"; // hairline
86
87fn paint(style: Style, sgr: &str, text: &str) -> String {
88    if style.color {
89        format!("\x1b[{sgr}m{text}\x1b[0m")
90    } else {
91        text.to_string()
92    }
93}
94
95/// The Claude Code status line in the Context Gateway's terms:
96/// `◆ LeanCTX │ SELECT 12 files · 30 commands │ CONTROL ⛨ 3 enforced │ TOKENS −1.2M kept out · 60% leaner · 41 cached`.
97/// SELECT is what reached Claude through the gateway, CONTROL what the rules
98/// enforced before the handoff, TOKENS what stayed out of context. A group with
99/// nothing measured is left out, and so is the line when all are.
100pub fn brand_line(snap: &ValueSnapshot, style: Style) -> Option<String> {
101    let sources = snap.files_read + snap.commands_run;
102    if snap.is_empty() && sources == 0 {
103        return None;
104    }
105    let sep = style.sep();
106    let rule = if style.unicode { " │ " } else { " | " };
107    let group = |label: &str, value: String, details: &[String]| {
108        let mut out = format!(
109            "{} {}",
110            paint(style, BRAND_LABEL, label),
111            paint(style, BRAND_VALUE, &value)
112        );
113        for detail in details {
114            out.push_str(&paint(style, BRAND_DETAIL, &format!("{sep}{detail}")));
115        }
116        out
117    };
118    let mut groups = Vec::new();
119    if sources > 0 {
120        let mut parts = Vec::new();
121        if snap.files_read > 0 {
122            parts.push(plural(snap.files_read, "file", "files"));
123        }
124        if snap.commands_run > 0 {
125            parts.push(plural(snap.commands_run, "command", "commands"));
126        }
127        let (value, details) = parts.split_first().expect("sources > 0 names one part");
128        groups.push(group("SELECT", value.clone(), details));
129    }
130    let security = snap.security.total();
131    if security > 0 {
132        groups.push(group(
133            "CONTROL",
134            format!("{} {security} enforced", style.shield()),
135            &[],
136        ));
137    }
138    if snap.tokens_saved > 0 {
139        let mut details = Vec::new();
140        if let Some(pct) = snap.saved_pct() {
141            details.push(format!("{pct:.0}% leaner"));
142        }
143        if snap.cache_hits > 0 {
144            details.push(format!("{} cached", snap.cache_hits));
145        }
146        let value = format!(
147            "{}{} kept out",
148            style.minus(),
149            format_tokens(snap.tokens_saved)
150        );
151        groups.push(group("TOKENS", value, &details));
152    }
153    let brand = format!("{} LeanCTX", style.mark());
154    let lead = if style.color {
155        paint(style, BRAND_PILL, &format!(" {brand} "))
156    } else {
157        brand
158    };
159    let rule = paint(style, BRAND_RULE, rule);
160    Some(format!("{lead}{rule}{}", groups.join(&rule)))
161}
162
163/// `◆ −1.2M tok ⛨ 3` for a shell prompt: the shortest honest form, never
164/// coloured (prompt escaping differs per shell, so the caller wraps it).
165pub fn compact(snap: &ValueSnapshot, style: Style) -> Option<String> {
166    if snap.is_empty() {
167        return None;
168    }
169    let mut out = style.mark().to_string();
170    if snap.tokens_saved > 0 {
171        out.push_str(&format!(
172            " {}{} tok",
173            style.minus(),
174            format_tokens(snap.tokens_saved)
175        ));
176    }
177    let security = snap.security.total();
178    if security > 0 {
179        out.push_str(&format!(" {} {security}", style.shield()));
180    }
181    // Only cache hits measured: nothing short enough to be worth a prompt slot.
182    (out.len() > style.mark().len()).then_some(out)
183}
184
185fn plural(n: u64, one: &str, many: &str) -> String {
186    format!("{n} {}", if n == 1 { one } else { many })
187}
188
189/// Human wording for a security tally, in the contract's words: secrets are
190/// *kept out of context*, commands and paths *blocked*, injections *flagged*.
191pub fn security_phrases(counts: &SecurityCounts) -> Vec<String> {
192    let mut out = Vec::new();
193    if counts.secrets_redacted > 0 {
194        out.push(format!(
195            "{} kept out of context",
196            plural(counts.secrets_redacted, "secret", "secrets")
197        ));
198    }
199    if counts.shell_blocked > 0 {
200        out.push(format!(
201            "{} blocked",
202            plural(counts.shell_blocked, "risky command", "risky commands")
203        ));
204    }
205    if counts.path_blocked > 0 {
206        out.push(format!(
207            "{} outside the project blocked",
208            plural(counts.path_blocked, "path", "paths")
209        ));
210    }
211    if counts.injection_flagged > 0 {
212        out.push(format!(
213            "{} flagged",
214            plural(
215                counts.injection_flagged,
216                "prompt-injection pattern",
217                "prompt-injection patterns"
218            )
219        ));
220    }
221    if counts.pii_redacted > 0 {
222        out.push(format!(
223            "{} kept out of context",
224            plural(
225                counts.pii_redacted,
226                "personal data value",
227                "personal data values"
228            )
229        ));
230    }
231    if counts.content_withheld > 0 {
232        out.push(format!(
233            "{} withheld",
234            plural(counts.content_withheld, "source", "sources")
235        ));
236    }
237    if counts.coverage_incomplete > 0 {
238        out.push(format!(
239            "{} not fully inspected",
240            plural(counts.coverage_incomplete, "source", "sources")
241        ));
242    }
243    out
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn snap(saved: u64, cache: u64, secrets: u64) -> ValueSnapshot {
251        let mut s = ValueSnapshot {
252            tokens_saved: saved,
253            tokens_input: saved * 2,
254            cache_hits: cache,
255            ..ValueSnapshot::default()
256        };
257        s.security.secrets_redacted = secrets;
258        s
259    }
260
261    #[test]
262    fn nothing_measured_renders_nothing() {
263        assert_eq!(one_line(&ValueSnapshot::default(), Style::PLAIN), None);
264    }
265
266    #[test]
267    fn plain_unicode_line() {
268        assert_eq!(
269            one_line(&snap(1_200_000, 41, 3), Style::PLAIN).unwrap(),
270            "◆ lean-ctx −1.2M tok · 41 cached · ⛨ 3"
271        );
272    }
273
274    #[test]
275    fn ascii_fallback_has_no_multibyte_glyphs() {
276        let style = Style {
277            color: false,
278            unicode: false,
279        };
280        let line = one_line(&snap(312_000, 0, 1), style).unwrap();
281        assert!(line.is_ascii(), "{line}");
282        assert_eq!(line, "* lean-ctx -312.0K tok | sec 1");
283    }
284
285    #[test]
286    fn color_is_dim_and_reset() {
287        let style = Style {
288            color: true,
289            unicode: true,
290        };
291        let line = one_line(&snap(5_000, 0, 0), style).unwrap();
292        assert!(line.starts_with("\x1b[2m") && line.ends_with("\x1b[0m"));
293    }
294
295    #[test]
296    fn compact_is_short_and_uncoloured() {
297        let style = Style {
298            color: true,
299            unicode: true,
300        };
301        assert_eq!(
302            compact(&snap(1_200_000, 41, 3), style).unwrap(),
303            "◆ −1.2M tok ⛨ 3"
304        );
305        let ascii = Style {
306            color: false,
307            unicode: false,
308        };
309        assert_eq!(compact(&snap(0, 0, 2), ascii).unwrap(), "* sec 2");
310        assert_eq!(compact(&snap(0, 9, 0), Style::PLAIN), None);
311        assert_eq!(compact(&ValueSnapshot::default(), Style::PLAIN), None);
312    }
313
314    #[test]
315    fn brand_line_groups_select_control_and_tokens() {
316        let mut full = snap(1_200_000, 41, 3);
317        full.files_read = 12;
318        full.commands_run = 30;
319        assert_eq!(
320            brand_line(&full, Style::PLAIN).unwrap(),
321            "◆ LeanCTX │ SELECT 12 files · 30 commands │ CONTROL ⛨ 3 enforced │ TOKENS −1.2M kept out · 50% leaner · 41 cached"
322        );
323        assert_eq!(
324            brand_line(&snap(0, 0, 2), Style::PLAIN).unwrap(),
325            "◆ LeanCTX │ CONTROL ⛨ 2 enforced"
326        );
327        // Sources read with nothing kept out still say what reached Claude.
328        let read_only = ValueSnapshot {
329            files_read: 1,
330            ..ValueSnapshot::default()
331        };
332        assert_eq!(
333            brand_line(&read_only, Style::PLAIN).unwrap(),
334            "◆ LeanCTX │ SELECT 1 file"
335        );
336        assert_eq!(brand_line(&ValueSnapshot::default(), Style::PLAIN), None);
337    }
338
339    #[test]
340    fn brand_line_colour_and_ascii_fallbacks() {
341        let colour = Style {
342            color: true,
343            unicode: true,
344        };
345        let line = brand_line(&snap(5_000, 0, 0), colour).unwrap();
346        assert!(line.starts_with("\x1b[1;38;2;17;22;38;48;2;102;140;255m ◆ LeanCTX \x1b[0m"));
347        assert!(line.ends_with("\x1b[0m"));
348        let ascii = Style {
349            color: false,
350            unicode: false,
351        };
352        let line = brand_line(&snap(312_000, 2, 1), ascii).unwrap();
353        assert!(line.is_ascii(), "{line}");
354        assert_eq!(
355            line,
356            "* LeanCTX | CONTROL sec 1 enforced | TOKENS -312.0K kept out | 50% leaner | 2 cached"
357        );
358    }
359
360    #[test]
361    fn security_only_session_still_shows() {
362        let line = one_line(&snap(0, 0, 2), Style::PLAIN).unwrap();
363        assert_eq!(line, "◆ lean-ctx ⛨ 2");
364    }
365
366    #[test]
367    fn phrases_never_claim_neutralization() {
368        let counts = SecurityCounts {
369            secrets_redacted: 1,
370            shell_blocked: 2,
371            path_blocked: 1,
372            injection_flagged: 3,
373            ..SecurityCounts::ZERO
374        };
375        let text = security_phrases(&counts).join(", ");
376        assert_eq!(
377            text,
378            "1 secret kept out of context, 2 risky commands blocked, \
379             1 path outside the project blocked, 3 prompt-injection patterns flagged"
380        );
381        assert!(!text.contains("neutraliz"));
382    }
383
384    #[test]
385    fn gateway_phrases_say_what_happened_without_overclaiming() {
386        let counts = SecurityCounts {
387            pii_redacted: 2,
388            content_withheld: 1,
389            coverage_incomplete: 1,
390            ..SecurityCounts::ZERO
391        };
392        assert_eq!(
393            security_phrases(&counts).join(", "),
394            "2 personal data values kept out of context, 1 source withheld, \
395             1 source not fully inspected"
396        );
397    }
398}