winx_code_agent/utils/output_compress.rs
1//! Conscious compression of noisy shell output.
2//!
3//! The goal is token economy **without losing context**. We never summarize,
4//! paraphrase, or drop unique information. The only thing collapsed is
5//! *mechanical repetition*, which carries no extra meaning:
6//! - runs of byte-identical consecutive lines -> one line + an `[×N]` marker
7//! - runs of 3+ blank lines -> a single blank line
8//!
9//! Deliberately conservative choices so we never eat real context:
10//! - lines that merely differ (e.g. "Test 1 passed" / "Test 2 passed", or a
11//! compiler's per-file diagnostics) are left untouched — those are content;
12//! - progress bars that redraw with `\r` are already collapsed upstream by the
13//! PTY ring buffer, so there's nothing speculative to guess at here;
14//! - an `[×N]` marker is fully reversible information — the reader knows the
15//! exact line and how many times it repeated.
16//!
17//! Set `WINX_NO_COMPRESS=1` to disable entirely.
18
19/// Don't bother compressing output shorter than this many lines.
20const MIN_LINES: usize = 30;
21/// Only collapse a run of identical lines once it repeats at least this often.
22const RUN_MIN: usize = 3;
23/// Only return a compressed result if it removes at least this many lines —
24/// otherwise the footer isn't worth the noise.
25const MIN_SAVED_LINES: usize = 8;
26
27/// Collapse mechanical repetition in `output`. Returns `None` when compression
28/// is disabled, the output is too short, or there's nothing meaningful to save —
29/// callers should fall back to the original text in that case.
30pub fn compress_output(output: &str) -> Option<String> {
31 if disabled() {
32 return None;
33 }
34 let lines: Vec<&str> = output.split('\n').collect();
35 if lines.len() < MIN_LINES {
36 return None;
37 }
38
39 let mut out: Vec<String> = Vec::with_capacity(lines.len());
40 let mut saved = 0usize;
41 let mut i = 0;
42 while i < lines.len() {
43 let line = lines[i];
44 let mut j = i + 1;
45 while j < lines.len() && lines[j] == line {
46 j += 1;
47 }
48 let run = j - i;
49
50 if run >= RUN_MIN {
51 if line.is_empty() {
52 // Collapse a run of 3+ truly-empty lines to a single blank.
53 out.push(String::new());
54 } else {
55 // Collapse identical lines, keeping the count (reversible). Note
56 // `line.is_empty()`, not `trim().is_empty()`: a whitespace-only
57 // line (" ", "\t") carries content (diffs, `cat -A`, YAML) and
58 // must round-trip exactly, so it takes the marker path instead of
59 // being flattened to empty.
60 out.push(format!("{line} [winx: ×{run}]"));
61 }
62 saved += run - 1;
63 } else {
64 // Distinct content or a short run — keep verbatim.
65 for keep in &lines[i..j] {
66 out.push((*keep).to_string());
67 }
68 }
69 i = j;
70 }
71
72 if saved < MIN_SAVED_LINES {
73 return None;
74 }
75
76 let compressed_count = out.len();
77 out.push(format!(
78 "[winx: collapsed {saved} repeated lines ({} → {compressed_count}); \
79 set WINX_NO_COMPRESS=1 to see raw output]",
80 lines.len()
81 ));
82 Some(out.join("\n"))
83}
84
85fn disabled() -> bool {
86 std::env::var("WINX_NO_COMPRESS").is_ok_and(|value| {
87 let value = value.trim();
88 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
89 })
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn short_output_is_left_alone() {
98 let out = "line\n".repeat(5);
99 assert!(compress_output(&out).is_none());
100 }
101
102 #[test]
103 fn collapses_identical_run_but_keeps_count() -> Result<(), String> {
104 // 50 identical "retrying..." lines surrounded by distinct content.
105 let mut text = String::from("start\n");
106 for _ in 0..50 {
107 text.push_str("retrying connection...\n");
108 }
109 text.push_str("done\n");
110 let compressed = compress_output(&text).ok_or("should compress")?;
111 assert!(compressed.contains("retrying connection... [winx: ×50]"));
112 assert!(compressed.contains("start"));
113 assert!(compressed.contains("done"));
114 // the 50 repeats became 1 line + footer
115 assert!(compressed.lines().count() < 10);
116 Ok(())
117 }
118
119 #[test]
120 fn preserves_whitespace_only_lines() -> Result<(), String> {
121 // A run of identical whitespace-only lines carries content (diffs,
122 // `cat -A`, YAML indentation) and must round-trip via the [×N] marker —
123 // never be flattened to empty like a truly-blank run.
124 let mut text = String::from("start\n");
125 for _ in 0..40 {
126 text.push_str(" \n"); // three spaces — NOT empty
127 }
128 text.push_str("done\n");
129 let compressed = compress_output(&text).ok_or("should compress")?;
130 assert!(
131 compressed.contains(" "),
132 "whitespace content must survive compression: {compressed:?}"
133 );
134 assert!(compressed.contains("[winx: ×40]"), "got: {compressed:?}");
135 Ok(())
136 }
137
138 #[test]
139 fn keeps_distinct_lines_that_only_differ_by_number() {
140 use std::fmt::Write as _;
141 // Per-item lines carry real info and must NOT be collapsed.
142 let mut text = String::new();
143 for n in 0..40 {
144 let _ = writeln!(text, "Test {n} passed");
145 }
146 // Nothing identical repeats, so there's nothing to collapse.
147 assert!(compress_output(&text).is_none());
148 }
149
150 #[test]
151 fn disabled_via_env_returns_none() {
152 // Can't safely toggle env in parallel tests; just assert the helper logic
153 // by confirming a compressible payload compresses when env is unset.
154 let text = "spam\n".repeat(40);
155 // (env unset in CI) -> compresses
156 if !disabled() {
157 assert!(compress_output(&text).is_some());
158 }
159 }
160}