From c61a5b9a56d3d4592787bc28a74f03efdd9a65fc Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Apr 2026 11:00:55 +0200 Subject: [PATCH] feat(config): added minimizer output fields and compact shell summaries - Added minimizer API fields (`MinimizerResult.text`, `settingsHash`) and shell options for minimized output behavior. - Changed bash execution to print minimized text output and append artifact footers only after minimized output is saved. - Added UTF-8-safe shell streaming with `on_chunk` callback routing and `replace()` output sink updates. - Added core filter enhancements for git, cargo, go, and listing/python outputs to produce compact summaries. - Added RTK command filters and fixtures to strip noise, truncate output, and normalize success fallbacks. --- crates/pi-natives/src/shell.rs | 112 ++- .../minimizer/defs/ansible-playbook.toml | 37 + .../shell/minimizer/defs/basedpyright.toml | 50 ++ .../src/shell/minimizer/defs/biome.toml | 48 ++ .../shell/minimizer/defs/brew-install.toml | 41 + .../shell/minimizer/defs/bundle-install.toml | 65 ++ .../minimizer/defs/composer-install.toml | 44 + .../src/shell/minimizer/defs/df.toml | 19 + .../shell/minimizer/defs/dotnet-build.toml | 68 ++ .../src/shell/minimizer/defs/du.toml | 19 + .../shell/minimizer/defs/fail2ban-client.toml | 18 + .../src/shell/minimizer/defs/gcc.toml | 52 ++ .../src/shell/minimizer/defs/hadolint.toml | 27 + .../src/shell/minimizer/defs/jq.toml | 27 + .../src/shell/minimizer/defs/make.toml | 44 + .../shell/minimizer/defs/markdownlint.toml | 27 + .../src/shell/minimizer/defs/mix-compile.toml | 31 + .../src/shell/minimizer/defs/mix-format.toml | 19 + .../src/shell/minimizer/defs/mvn-build.toml | 48 ++ .../src/shell/minimizer/defs/oxlint.toml | 46 ++ .../src/shell/minimizer/defs/pio-run.toml | 44 + .../shell/minimizer/defs/poetry-install.toml | 54 ++ .../shell/minimizer/defs/quarto-render.toml | 45 + .../src/shell/minimizer/defs/shellcheck.toml | 30 + .../src/shell/minimizer/defs/stat.toml | 47 ++ .../minimizer/defs/systemctl-status.toml | 37 + .../shell/minimizer/defs/terraform-plan.toml | 39 + .../src/shell/minimizer/defs/tofu-fmt.toml | 20 + .../src/shell/minimizer/defs/tofu-init.toml | 42 + .../src/shell/minimizer/defs/tofu-plan.toml | 39 + .../shell/minimizer/defs/tofu-validate.toml | 21 + .../src/shell/minimizer/defs/trunk-build.toml | 43 + .../src/shell/minimizer/defs/uv-sync.toml | 41 + .../src/shell/minimizer/defs/yamllint.toml | 28 + .../pi-natives/src/shell/minimizer/engine.rs | 4 +- .../src/shell/minimizer/filters/cargo.rs | 124 ++- .../src/shell/minimizer/filters/git.rs | 421 ++++++++-- .../src/shell/minimizer/filters/go.rs | 30 + .../src/shell/minimizer/filters/gt.rs | 4 +- .../src/shell/minimizer/filters/listing.rs | 767 +++++++++++++++++- .../src/shell/minimizer/filters/python.rs | 21 + packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/exec/bash-executor.ts | 30 +- .../src/session/streaming-output.ts | 11 + packages/natives/CHANGELOG.md | 10 +- packages/natives/native/index.d.ts | 18 +- 46 files changed, 2694 insertions(+), 120 deletions(-) create mode 100644 crates/pi-natives/src/shell/minimizer/defs/ansible-playbook.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/basedpyright.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/biome.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/brew-install.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/bundle-install.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/composer-install.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/df.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/dotnet-build.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/du.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/fail2ban-client.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/gcc.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/hadolint.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/jq.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/make.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/markdownlint.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/mix-compile.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/mix-format.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/mvn-build.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/oxlint.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/pio-run.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/poetry-install.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/quarto-render.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/shellcheck.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/stat.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/systemctl-status.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/terraform-plan.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/tofu-fmt.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/tofu-init.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/tofu-plan.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/tofu-validate.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/trunk-build.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/uv-sync.toml create mode 100644 crates/pi-natives/src/shell/minimizer/defs/yamllint.toml diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index fb7d3e55a..8e03bbe1d 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -130,13 +130,18 @@ pub struct ShellRunOptions<'env> { /// /// Surfaced when the minimizer actually rewrote the command's output. The /// session layer is expected to persist `original_text` via its -/// `ArtifactManager` and splice the resulting `artifact://` reference -/// into whatever is shown to the agent. +/// `ArtifactManager`, splice the resulting `artifact://` reference +/// into `text`, and replace any previously streamed raw output with the +/// minimized text. #[napi(object)] pub struct MinimizerResult { /// Dispatch label produced by the minimizer (e.g. `"git"`, /// `"pipeline:gradle"`, `"pipeline+builtin"`). pub filter: String, + /// The minimized replacement text. Callers that streamed raw chunks + /// during execution should clear and replace their accumulated output + /// with this text. + pub text: String, /// The full original capture, before minimization. pub original_text: String, /// Captured byte length before minimization. @@ -669,18 +674,24 @@ async fn run_shell_command( let reader_cancel = CancellationToken::new(); let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1); - let (reader_callback, final_callback) = if should_minimize { - (None, on_chunk) - } else { - (on_chunk, None) - }; + // Stream every raw chunk to the caller live, regardless of whether + // minimization is enabled. When minimization actually transforms the + // output, we propagate the replacement text via `MinimizerResult.text` + // so the caller can swap their accumulated buffer for the minimized + // version without losing intermediate progress updates. + let reader_callback = on_chunk; let mut reader_handle = tokio::spawn({ let reader_cancel = reader_cancel.clone(); async move { if should_minimize { - let output = - read_output_buffered(reader_file, reader_cancel, activity_tx, max_capture_bytes) - .await; + let output = read_output_buffered( + reader_file, + reader_callback, + reader_cancel, + activity_tx, + max_capture_bytes, + ) + .await; Result::::Ok(OutputRead::Buffered(output)) } else { Box::pin(read_output(reader_file, reader_callback, reader_cancel, activity_tx)).await; @@ -770,12 +781,22 @@ async fn run_shell_command( && let Some(config) = options.minimizer.as_ref() { if output.exceeded { - let text = if let Some(marker_state) = marker_state.as_ref() { - minimizer::engine::strip_markers(&output.text, marker_state) - } else { - output.text - }; - emit_chunk(&text, final_callback.as_ref()); + // Exceeded captures still flow through the raw stream already. Only + // surface a replacement when we actually need to strip markers. + if let Some(marker_state) = marker_state.as_ref() { + let stripped = minimizer::engine::strip_markers(&output.text, marker_state); + if stripped != output.text { + let input_bytes = u32::try_from(output.text.len()).unwrap_or(u32::MAX); + let output_bytes = u32::try_from(stripped.len()).unwrap_or(u32::MAX); + minimized_out = Some(MinimizerResult { + filter: "marker-strip".to_string(), + text: stripped, + original_text: output.text, + input_bytes, + output_bytes, + }); + } + } } else { let minimized = match (minimizer_mode, marker_state.as_ref()) { (minimizer::engine::MinimizerMode::WholeCommand, _) => { @@ -786,15 +807,16 @@ async fn run_shell_command( }, _ => minimizer::MinimizerOutput::passthrough(&output.text), }; - emit_chunk(&minimized.text, final_callback.as_ref()); if minimized.changed && let Some(original) = minimized.original_text { + let output_bytes = u32::try_from(minimized.text.len()).unwrap_or(u32::MAX); minimized_out = Some(MinimizerResult { - filter: minimized.filter.to_string(), + filter: minimized.filter.to_string(), + text: minimized.text, original_text: original, - input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX), - output_bytes: u32::try_from(minimized.text.len()).unwrap_or(u32::MAX), + input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX), + output_bytes, }); } } @@ -1060,14 +1082,20 @@ async fn read_output( async fn read_output_buffered( reader: fs::File, + on_chunk: Option>, cancel_token: CancellationToken, activity: mpsc::Sender<()>, max_capture_bytes: usize, ) -> BufferedOutput { + const REPLACEMENT: &str = "\u{FFFD}"; const BUF: usize = 65536; let mut buf = vec![0u8; BUF]; let mut captured = Vec::new(); let mut exceeded = false; + // Pending bytes from a prior read that ended mid-UTF-8 sequence. We hold + // them back so we emit only valid UTF-8 to the streaming callback while + // still capturing every byte into `captured` for post-processing. + let mut pending = Vec::::new(); #[cfg(unix)] let Ok(reader) = register_nonblocking_pipe(reader) else { @@ -1116,6 +1144,50 @@ async fn read_output_buffered( exceeded = true; } captured.extend_from_slice(&buf[..n]); + + // Stream whatever is validly decodable *right now* to the callback, + // carrying incomplete trailing UTF-8 bytes over to the next iteration. + if let Some(cb) = on_chunk.as_ref() { + pending.extend_from_slice(&buf[..n]); + while !pending.is_empty() { + match str::from_utf8(&pending) { + Ok(text) => { + emit_chunk(text, Some(cb)); + pending.clear(); + break; + }, + Err(err) => { + let p = err.valid_up_to(); + if p > 0 { + // SAFETY: [..p] is valid UTF-8 per valid_up_to(). + let text = unsafe { str::from_utf8_unchecked(&pending[..p]) }; + emit_chunk(text, Some(cb)); + pending.drain(..p); + } + match err.error_len() { + Some(skip) => { + emit_chunk(REPLACEMENT, Some(cb)); + pending.drain(..skip); + }, + None => break, + } + }, + } + } + } + } + + // Flush any trailing bytes the streaming decoder held back at EOF. + if let Some(cb) = on_chunk.as_ref() { + for chunk in pending.utf8_chunks() { + let valid = chunk.valid(); + if !valid.is_empty() { + emit_chunk(valid, Some(cb)); + } + if !chunk.invalid().is_empty() { + emit_chunk(REPLACEMENT, Some(cb)); + } + } } BufferedOutput { text: String::from_utf8_lossy(&captured).into_owned(), exceeded } diff --git a/crates/pi-natives/src/shell/minimizer/defs/ansible-playbook.toml b/crates/pi-natives/src/shell/minimizer/defs/ansible-playbook.toml new file mode 100644 index 000000000..e7a833d3b --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/ansible-playbook.toml @@ -0,0 +1,37 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/ansible-playbook.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.ansible-playbook] +description = "Compact ansible-playbook output" +match_command = "^ansible-playbook$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^ok: \\[", + "^skipping: \\[", +] +max_lines = 60 + +[[tests.ansible-playbook]] +name = "strips ok and skipping lines, keeps changed and failures" +input = """ +PLAY [all] ********************************************************************* + +TASK [Gathering Facts] ********************************************************* +ok: [web01] +ok: [web02] + +TASK [Install nginx] *********************************************************** +changed: [web01] +skipping: [web02] + +PLAY RECAP ********************************************************************* +web01 : ok=2 changed=1 unreachable=0 failed=0 +web02 : ok=1 changed=0 unreachable=0 failed=0 +""" +expected = "PLAY [all] *********************************************************************\nTASK [Gathering Facts] *********************************************************\nTASK [Install nginx] ***********************************************************\nchanged: [web01]\nPLAY RECAP *********************************************************************\nweb01 : ok=2 changed=1 unreachable=0 failed=0\nweb02 : ok=1 changed=0 unreachable=0 failed=0\n" + +[[tests.ansible-playbook]] +name = "failed task preserved" +input = "TASK [Start service] ***\nfailed: [web01] => {\"msg\": \"Service not found\"}\nPLAY RECAP ***\nweb01 : ok=1 failed=1" +expected = "TASK [Start service] ***\nfailed: [web01] => {\"msg\": \"Service not found\"}\nPLAY RECAP ***\nweb01 : ok=1 failed=1\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/basedpyright.toml b/crates/pi-natives/src/shell/minimizer/defs/basedpyright.toml new file mode 100644 index 000000000..b82c0a6ff --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/basedpyright.toml @@ -0,0 +1,50 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/basedpyright.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.basedpyright] +description = "Compact basedpyright type checker output — strip blank lines, keep errors" +match_command = "^basedpyright$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Searching for source files", + "^Found \\d+ source file", + "^Pyright \\d+\\.\\d+", + "^basedpyright \\d+\\.\\d+", +] +max_lines = 50 +on_empty = "basedpyright: ok" + +[[tests.basedpyright]] +name = "strips noise, keeps errors and summary" +input = """ +basedpyright 1.22.0 +Searching for source files +Found 42 source files + +/home/user/app/main.py + /home/user/app/main.py:10:5 - error: "foo" is not defined (reportUndefinedVariable) + /home/user/app/main.py:25:1 - error: Type "str" is not assignable to type "int" (reportAssignmentType) + +/home/user/app/utils.py + /home/user/app/utils.py:8:9 - warning: Variable "x" is not accessed (reportUnusedVariable) + +3 errors, 1 warning, 0 informations +""" +expected = "/home/user/app/main.py\n /home/user/app/main.py:10:5 - error: \"foo\" is not defined (reportUndefinedVariable)\n /home/user/app/main.py:25:1 - error: Type \"str\" is not assignable to type \"int\" (reportAssignmentType)\n/home/user/app/utils.py\n /home/user/app/utils.py:8:9 - warning: Variable \"x\" is not accessed (reportUnusedVariable)\n3 errors, 1 warning, 0 informations\n" + +[[tests.basedpyright]] +name = "clean output" +input = """ +basedpyright 1.22.0 +Searching for source files +Found 10 source files + +0 errors, 0 warnings, 0 informations +""" +expected = "0 errors, 0 warnings, 0 informations\n" + +[[tests.basedpyright]] +name = "empty input returns on_empty message" +input = "" +expected = "basedpyright: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/biome.toml b/crates/pi-natives/src/shell/minimizer/defs/biome.toml new file mode 100644 index 000000000..357cce9c1 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/biome.toml @@ -0,0 +1,48 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/biome.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.biome] +description = "Compact Biome lint/format output — strip blank lines, keep diagnostics" +match_command = "^biome$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Checked \\d+ file", + "^Fixed \\d+ file", + "^The following command", + "^Run it with", +] +max_lines = 50 +on_empty = "biome: ok" + +[[tests.biome]] +name = "lint strips noise, keeps diagnostics" +input = """ +Checked 42 files in 0.5s + +src/app.tsx:5:3 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━ + × Unexpected any. Specify a different type. + 3 │ interface Props { + 4 │ data: any; + 5 │ ^^^ + +src/utils.ts:12:1 lint/complexity/noForEach ━━━━━━━━━━━━━━━━━━━━ + × Prefer for...of instead of forEach. + 12 │ items.forEach(item => process(item)); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Found 2 errors. +""" +expected = "src/app.tsx:5:3 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━\n × Unexpected any. Specify a different type.\n 3 │ interface Props {\n 4 │ data: any;\n 5 │ ^^^\nsrc/utils.ts:12:1 lint/complexity/noForEach ━━━━━━━━━━━━━━━━━━━━\n × Prefer for...of instead of forEach.\n 12 │ items.forEach(item => process(item));\n │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nFound 2 errors.\n" + +[[tests.biome]] +name = "clean check" +input = """ +Checked 42 files in 0.3s +""" +expected = "biome: ok" + +[[tests.biome]] +name = "empty input returns on_empty message" +input = "" +expected = "biome: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/brew-install.toml b/crates/pi-natives/src/shell/minimizer/defs/brew-install.toml new file mode 100644 index 000000000..f58db9de6 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/brew-install.toml @@ -0,0 +1,41 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/brew-install.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.brew-install] +description = "Compact brew install/upgrade output — strip downloads, short-circuit when already installed" +match_command = "^brew$" +match_subcommand = "^(install|upgrade)$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^==> Downloading", + "^==> Pouring", + "^Already downloaded:", + "^###", + "^==> Fetching", +] +match_output = [ + { pattern = "already installed", message = "ok (already installed)" }, +] +max_lines = 20 + +[[tests.brew-install]] +name = "already installed short-circuits" +input = """ +Warning: rtk 0.27.1 is already installed and up-to-date. +To reinstall 0.27.1, run: + brew reinstall rtk +""" +expected = "ok (already installed)" + +[[tests.brew-install]] +name = "install strips download lines" +input = """ +==> Fetching jq +==> Downloading https://homebrew.bintray.com/bottles/jq-1.7.1.arm64_sonoma.bottle.tar.gz +######################################################################## 100.0% +==> Pouring jq-1.7.1.arm64_sonoma.bottle.tar.gz +==> Summary +/opt/homebrew/Cellar/jq/1.7.1: 18 files, 1.2MB +""" +expected = "==> Summary\n/opt/homebrew/Cellar/jq/1.7.1: 18 files, 1.2MB\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/bundle-install.toml b/crates/pi-natives/src/shell/minimizer/defs/bundle-install.toml new file mode 100644 index 000000000..b8978f050 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/bundle-install.toml @@ -0,0 +1,65 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/bundle-install.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.bundle-install] +description = "Compact bundle install/update — strip 'Using' lines, keep installs and errors" +match_command = "^bundle$" +match_subcommand = "^(install|update)$" +strip_ansi = true +strip_lines_matching = [ + "^Using ", + "^\\s*$", + "^Fetching gem metadata", + "^Resolving dependencies", +] +match_output = [ + { pattern = "Bundle complete!", message = "ok bundle: complete" }, + { pattern = "Bundle updated!", message = "ok bundle: updated" }, +] +max_lines = 30 + +[[tests.bundle-install]] +name = "all cached short-circuits" +input = """ +Using bundler 2.5.6 +Using rake 13.1.0 +Using ast 2.4.2 +Using base64 0.2.0 +Using minitest 5.22.2 +Bundle complete! 85 Gemfile dependencies, 200 gems now installed. +Use `bundle info [gemname]` to see where a bundled gem is installed. +""" +expected = "ok bundle: complete" + +[[tests.bundle-install]] +name = "mixed install keeps Fetching and Installing lines" +input = """ +Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.1.0 +Using ast 2.4.2 +Fetching rspec 3.13.0 +Installing rspec 3.13.0 +Using rubocop 1.62.0 +Fetching simplecov 0.22.0 +Installing simplecov 0.22.0 +Bundle complete! 85 Gemfile dependencies, 202 gems now installed. +""" +expected = "ok bundle: complete" + +[[tests.bundle-install]] +name = "update output" +input = """ +Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.1.0 +Fetching rspec 3.14.0 (was 3.13.0) +Installing rspec 3.14.0 (was 3.13.0) +Bundle updated! +""" +expected = "ok bundle: updated" + +[[tests.bundle-install]] +name = "empty output" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/composer-install.toml b/crates/pi-natives/src/shell/minimizer/defs/composer-install.toml new file mode 100644 index 000000000..159036954 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/composer-install.toml @@ -0,0 +1,44 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/composer-install.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.composer-install] +description = "Compact composer install/update/require output — strip downloads, short-circuit when up-to-date" +match_command = "^composer$" +match_subcommand = "^(install|update|require)$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^ - Downloading ", + "^ - Installing ", + "^Loading composer", + "^Updating dependencies", +] +match_output = [ + { pattern = "Nothing to install, update or remove", message = "ok (up to date)" }, +] +max_lines = 30 + +[[tests.composer-install]] +name = "nothing to do short-circuits" +input = """ +Loading composer repositories with package information +Updating dependencies +Lock file operations: 0 installs, 0 updates, 0 removals +Nothing to install, update or remove +Generating autoload files +""" +expected = "ok (up to date)" + +[[tests.composer-install]] +name = "install strips download lines" +input = """ +Loading composer repositories with package information +Updating dependencies + - Downloading symfony/console (v6.4.0) + - Installing symfony/console (v6.4.0): Extracting archive + - Downloading psr/log (3.0.0) + - Installing psr/log (3.0.0): Extracting archive +Writing lock file +Generating autoload files +""" +expected = "Writing lock file\nGenerating autoload files\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/df.toml b/crates/pi-natives/src/shell/minimizer/defs/df.toml new file mode 100644 index 000000000..b43e40e79 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/df.toml @@ -0,0 +1,19 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/df.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.df] +description = "Compact df output — truncate wide columns, limit rows" +match_command = "^df$" +strip_ansi = true +truncate_lines_at = 80 +max_lines = 20 + +[[tests.df]] +name = "short output passes through unchanged" +input = "Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/sda1 4096000 123456 3972544 4% /" +expected = "Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/sda1 4096000 123456 3972544 4% /\n" + +[[tests.df]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/dotnet-build.toml b/crates/pi-natives/src/shell/minimizer/defs/dotnet-build.toml new file mode 100644 index 000000000..3a7230533 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/dotnet-build.toml @@ -0,0 +1,68 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/dotnet-build.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.dotnet-build] +description = "Compact dotnet build output — short-circuit on success, strip banners" +match_command = "^dotnet$" +match_subcommand = "^build$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Microsoft \\(R\\)", + "^Copyright \\(C\\)", + "^ Determining projects", +] +match_output = [ + { pattern = "0 Warning\\(s\\)\\n\\s+0 Error\\(s\\)", message = "ok (build succeeded)" }, +] +max_lines = 40 + +[[tests.dotnet-build]] +name = "successful build short-circuits to ok" +input = """ +Microsoft (R) Build Engine version 17.8.3+195e7f5a3 +Copyright (C) Microsoft Corporation. All rights reserved. + + Determining projects to restore... + All projects are up-to-date for restore. + MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll + +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:02.34 +""" +expected = "ok (build succeeded)" + +[[tests.dotnet-build]] +name = "build with warnings not short-circuited" +input = """ +Microsoft (R) Build Engine version 17.8.3+195e7f5a3 +Copyright (C) Microsoft Corporation. All rights reserved. + + Determining projects to restore... + MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll + +Build succeeded. + 3 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:01.87 +""" +expected = " MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll\nBuild succeeded.\n 3 Warning(s)\n 0 Error(s)\nTime Elapsed 00:00:01.87\n" + +[[tests.dotnet-build]] +name = "build errors pass through" +input = """ +Microsoft (R) Build Engine version 17.8.3+195e7f5a3 +Copyright (C) Microsoft Corporation. All rights reserved. + + Determining projects to restore... +src/Program.cs(10,5): error CS1002: ; expected [/home/user/MyApp/MyApp.csproj] + +Build FAILED. + 0 Warning(s) + 1 Error(s) +""" +expected = "src/Program.cs(10,5): error CS1002: ; expected [/home/user/MyApp/MyApp.csproj]\nBuild FAILED.\n 0 Warning(s)\n 1 Error(s)\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/du.toml b/crates/pi-natives/src/shell/minimizer/defs/du.toml new file mode 100644 index 000000000..68d49602c --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/du.toml @@ -0,0 +1,19 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/du.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.du] +description = "Compact du output" +match_command = "^du$" +strip_lines_matching = ["^\\s*$"] +truncate_lines_at = 120 +max_lines = 40 + +[[tests.du]] +name = "preserves sizes, strips blank lines" +input = "4.0K\t./src\n\n8.0K\t./tests\n16K\t." +expected = "4.0K\t./src\n8.0K\t./tests\n16K\t.\n" + +[[tests.du]] +name = "single line passthrough" +input = "128K\t." +expected = "128K\t.\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/fail2ban-client.toml b/crates/pi-natives/src/shell/minimizer/defs/fail2ban-client.toml new file mode 100644 index 000000000..02d0ca999 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/fail2ban-client.toml @@ -0,0 +1,18 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/fail2ban-client.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.fail2ban-client] +description = "Compact fail2ban-client output" +match_command = "^fail2ban-client$" +strip_lines_matching = ["^\\s*$"] +max_lines = 30 + +[[tests.fail2ban-client]] +name = "strips blank lines" +input = "Status for the jail: sshd\n|- Filter\n| |- Currently failed: 3\n\n|- Actions\n `- Total banned: 42" +expected = "Status for the jail: sshd\n|- Filter\n| |- Currently failed: 3\n|- Actions\n `- Total banned: 42\n" + +[[tests.fail2ban-client]] +name = "single line passthrough" +input = "Shutdown successful" +expected = "Shutdown successful\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/gcc.toml b/crates/pi-natives/src/shell/minimizer/defs/gcc.toml new file mode 100644 index 000000000..f720aa277 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/gcc.toml @@ -0,0 +1,52 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/gcc.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.gcc] +description = "Compact gcc/g++ compiler output — strip notes, keep errors and warnings" +match_command = "^(gcc|g\\+\\+)$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s+\\|\\s*$", + "^In file included from", + "^\\s+from\\s", + "^\\d+ warnings? generated", + "^\\d+ errors? generated", +] +max_lines = 50 +on_empty = "gcc: ok" + +[[tests.gcc]] +name = "strips include chain, keeps errors and warnings" +input = """ +In file included from /usr/include/stdio.h:42: + from main.c:1: +main.c:10:5: error: use of undeclared identifier 'foo' + foo(); + ^ +main.c:15:12: warning: unused variable 'x' [-Wunused-variable] + int x = 42; + ^ +2 warnings generated. +1 error generated. +""" +expected = "main.c:10:5: error: use of undeclared identifier 'foo'\n foo();\n ^\nmain.c:15:12: warning: unused variable 'x' [-Wunused-variable]\n int x = 42;\n ^\n" + +[[tests.gcc]] +name = "clean compilation" +input = """ +""" +expected = "gcc: ok" + +[[tests.gcc]] +name = "linker error kept" +input = """ +/usr/bin/ld: /tmp/main.o: undefined reference to 'missing_func' +collect2: error: ld returned 1 exit status +""" +expected = "/usr/bin/ld: /tmp/main.o: undefined reference to 'missing_func'\ncollect2: error: ld returned 1 exit status\n" + +[[tests.gcc]] +name = "empty input returns on_empty message" +input = "" +expected = "gcc: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/hadolint.toml b/crates/pi-natives/src/shell/minimizer/defs/hadolint.toml new file mode 100644 index 000000000..bfa4f550b --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/hadolint.toml @@ -0,0 +1,27 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/hadolint.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.hadolint] +description = "Compact hadolint Dockerfile linting output" +match_command = "^hadolint$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", +] +truncate_lines_at = 120 +max_lines = 40 + +[[tests.hadolint]] +name = "Dockerfile warnings kept, blank lines stripped" +input = """ +Dockerfile:3 DL3008 warning: Pin versions in apt-get install +Dockerfile:5 DL3009 info: Delete apt-get lists after installing + +Dockerfile:8 DL4006 warning: Set SHELL option -o pipefail before RUN with pipe +""" +expected = "Dockerfile:3 DL3008 warning: Pin versions in apt-get install\nDockerfile:5 DL3009 info: Delete apt-get lists after installing\nDockerfile:8 DL4006 warning: Set SHELL option -o pipefail before RUN with pipe\n" + +[[tests.hadolint]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/jq.toml b/crates/pi-natives/src/shell/minimizer/defs/jq.toml new file mode 100644 index 000000000..2bf27ed25 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/jq.toml @@ -0,0 +1,27 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/jq.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.jq] +description = "Compact jq output — truncate large JSON results" +match_command = "^jq$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", +] +max_lines = 40 +truncate_lines_at = 120 + +[[tests.jq]] +name = "short output passes through" +input = """ +{ + "name": "test", + "version": "1.0" +} +""" +expected = "{\n \"name\": \"test\",\n \"version\": \"1.0\"\n}\n" + +[[tests.jq]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/make.toml b/crates/pi-natives/src/shell/minimizer/defs/make.toml new file mode 100644 index 000000000..f8ebc7436 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/make.toml @@ -0,0 +1,44 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/make.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.make] +description = "Compact make output" +match_command = "^make$" +strip_lines_matching = [ + "^make\\[\\d+\\]:", + "^\\s*$", + "^Nothing to be done", +] +max_lines = 50 +on_empty = "make: ok" + +[[tests.make]] +name = "strips entering/leaving lines" +input = """ +make[1]: Entering directory '/home/user' +gcc -O2 foo.c +make[1]: Leaving directory '/home/user' +""" +expected = """ +gcc -O2 foo.c +""" + +[[tests.make]] +name = "strips blank lines" +input = """ +gcc -O2 foo.c + +gcc -O2 bar.c +""" +expected = """ +gcc -O2 foo.c +gcc -O2 bar.c +""" + +[[tests.make]] +name = "on_empty when all stripped" +input = """ +make[1]: Entering directory '/home/user' +make[1]: Leaving directory '/home/user' +""" +expected = "make: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/markdownlint.toml b/crates/pi-natives/src/shell/minimizer/defs/markdownlint.toml new file mode 100644 index 000000000..1cd7ebfcb --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/markdownlint.toml @@ -0,0 +1,27 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/markdownlint.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.markdownlint] +description = "Compact markdownlint output — strip blank lines, limit rows" +match_command = "^markdownlint$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", +] +max_lines = 50 +truncate_lines_at = 120 + +[[tests.markdownlint]] +name = "linting errors stripped of blank lines" +input = """ +README.md:1:1 MD041/first-line-heading/first-line-h1 First line in file should be a top level heading +README.md:10:1 MD022/blanks-around-headings Headings should be surrounded by blank lines + +README.md:15:80 MD013/line-length Line length [Expected: 80; Actual: 95] +""" +expected = "README.md:1:1 MD041/first-line-heading/first-line-h1 First line in file should be a top level heading\nREADME.md:10:1 MD022/blanks-around-headings Headings should be surrounded by blank lines\nREADME.md:15:80 MD013/line-length Line length [Expected: 80; Actual: 95]\n" + +[[tests.markdownlint]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/mix-compile.toml b/crates/pi-natives/src/shell/minimizer/defs/mix-compile.toml new file mode 100644 index 000000000..c6a697870 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/mix-compile.toml @@ -0,0 +1,31 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mix-compile.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.mix-compile] +description = "Compact mix compile output" +match_command = "^mix$" +match_subcommand = "^compile$" +strip_ansi = true +strip_lines_matching = [ + "^Compiling \\d+ file", + "^\\s*$", + "^Generated\\s", +] +max_lines = 40 +on_empty = "mix compile: ok" + +[[tests.mix-compile]] +name = "strips compile noise, preserves warnings" +input = """ +Compiling 12 files (.ex) +Generated my_app app + +warning: variable "conn" is unused + lib/router.ex:42 +""" +expected = "warning: variable \"conn\" is unused\n lib/router.ex:42\n" + +[[tests.mix-compile]] +name = "on_empty when only noise" +input = "Compiling 3 files (.ex)\nGenerated my_app app\n" +expected = "mix compile: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/mix-format.toml b/crates/pi-natives/src/shell/minimizer/defs/mix-format.toml new file mode 100644 index 000000000..f5173d609 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/mix-format.toml @@ -0,0 +1,19 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mix-format.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.mix-format] +description = "Compact mix format output" +match_command = "^mix$" +match_subcommand = "^format$" +on_empty = "mix format: ok" +max_lines = 20 + +[[tests.mix-format]] +name = "empty output returns ok" +input = "" +expected = "mix format: ok" + +[[tests.mix-format]] +name = "changed files pass through" +input = "lib/my_app.ex\ntest/my_app_test.exs" +expected = "lib/my_app.ex\ntest/my_app_test.exs" diff --git a/crates/pi-natives/src/shell/minimizer/defs/mvn-build.toml b/crates/pi-natives/src/shell/minimizer/defs/mvn-build.toml new file mode 100644 index 000000000..020d10cc2 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/mvn-build.toml @@ -0,0 +1,48 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mvn-build.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.mvn-build] +description = "Compact Maven build output" +match_command = "^(mvn|mvnw)$" +match_subcommand = "^(compile|package|clean|install)$" +strip_ansi = true +strip_lines_matching = [ + "^\\[INFO\\] ---", + "^\\[INFO\\] Building\\s", + "^\\[INFO\\] Downloading\\s", + "^\\[INFO\\] Downloaded\\s", + "^\\[INFO\\]\\s*$", + "^\\s*$", + "^Downloading:", + "^Downloaded:", + "^Progress", +] +max_lines = 50 +on_empty = "mvn: ok" + +[[tests.mvn-build]] +name = "strips INFO noise, preserves errors and summary" +input = """ +[INFO] --- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] Downloading org.apache.maven.plugins:maven-compiler-plugin:3.11.0 +[INFO] Downloaded org.apache.maven.plugins:maven-compiler-plugin:3.11.0 +[INFO] +[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol + symbol: method foo() +[INFO] BUILD FAILURE +[INFO] Total time: 2.543 s +""" +expected = "[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol\n symbol: method foo()\n[INFO] BUILD FAILURE\n[INFO] Total time: 2.543 s\n" + +[[tests.mvn-build]] +name = "successful build keeps BUILD SUCCESS line" +input = """ +[INFO] --- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] +[INFO] BUILD SUCCESS +[INFO] Total time: 4.123 s +[INFO] Finished at: 2024-01-15T10:30:00Z +""" +expected = "[INFO] BUILD SUCCESS\n[INFO] Total time: 4.123 s\n[INFO] Finished at: 2024-01-15T10:30:00Z\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/oxlint.toml b/crates/pi-natives/src/shell/minimizer/defs/oxlint.toml new file mode 100644 index 000000000..286fcc953 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/oxlint.toml @@ -0,0 +1,46 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/oxlint.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.oxlint] +description = "Compact oxlint output — strip blank lines, keep diagnostics" +match_command = "^oxlint$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Finished in \\d+", + "^Found \\d+ warning", +] +max_lines = 50 +on_empty = "oxlint: ok" + +[[tests.oxlint]] +name = "strips noise, keeps diagnostics" +input = """ + × eslint(no-console): Unexpected console statement. + ╭─[src/app.ts:5:3] + 5 │ console.log("debug"); + │ ^^^^^^^^^^^ + ╰──── + + × eslint(no-unused-vars): 'x' is defined but never used. + ╭─[src/utils.ts:2:7] + 2 │ let x = 42; + │ ^ + ╰──── + +Found 2 warnings on 2 files. +Finished in 12ms on 100 files. +""" +expected = " × eslint(no-console): Unexpected console statement.\n ╭─[src/app.ts:5:3]\n 5 │ console.log(\"debug\");\n │ ^^^^^^^^^^^\n ╰────\n × eslint(no-unused-vars): 'x' is defined but never used.\n ╭─[src/utils.ts:2:7]\n 2 │ let x = 42;\n │ ^\n ╰────\n" + +[[tests.oxlint]] +name = "clean output" +input = """ +Finished in 5ms on 100 files. +""" +expected = "oxlint: ok" + +[[tests.oxlint]] +name = "empty input returns on_empty message" +input = "" +expected = "oxlint: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/pio-run.toml b/crates/pi-natives/src/shell/minimizer/defs/pio-run.toml new file mode 100644 index 000000000..6c3446d7d --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/pio-run.toml @@ -0,0 +1,44 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/pio-run.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.pio-run] +description = "Compact PlatformIO build output" +match_command = "^pio$" +match_subcommand = "^run$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Verbose mode", + "^CONFIGURATION:", + "^LDF:", + "^Library Manager:", + "^Compiling\\s", + "^Linking\\s", + "^Building\\s", + "^Checking size", +] +max_lines = 30 +on_empty = "pio run: ok" + +[[tests.pio-run]] +name = "strips build noise, preserves errors" +input = """ +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32dev.html +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +Compiling .pio/build/esp32dev/src/main.cpp.o +Building .pio/build/esp32dev/firmware.elf +Linking .pio/build/esp32dev/firmware.elf +Checking size .pio/build/esp32dev/firmware.elf +src/main.cpp:10:3: error: 'LED_BUILTINN' was not declared +""" +expected = "src/main.cpp:10:3: error: 'LED_BUILTINN' was not declared\n" + +[[tests.pio-run]] +name = "on_empty when clean build with only noise" +input = """ +Verbose mode can be enabled via `-v, --verbose` option +Compiling .pio/build/esp32dev/src/main.cpp.o +Linking .pio/build/esp32dev/firmware.elf +""" +expected = "pio run: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/poetry-install.toml b/crates/pi-natives/src/shell/minimizer/defs/poetry-install.toml new file mode 100644 index 000000000..105f67e8e --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/poetry-install.toml @@ -0,0 +1,54 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/poetry-install.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.poetry-install] +description = "Compact poetry install/lock/update output — strip downloads, short-circuit when up-to-date" +match_command = "^poetry$" +match_subcommand = "^(install|lock|update)$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^ [-•] Downloading ", + "^ [-•] Installing .* \\(", + "^Creating virtualenv", + "^Using virtualenv", +] +match_output = [ + { pattern = "No dependencies to install or update|No changes\\.", message = "ok (up to date)" }, +] +max_lines = 30 + +[[tests.poetry-install]] +name = "up to date short-circuits" +input = """ +Installing dependencies from lock file + +No dependencies to install or update +""" +expected = "ok (up to date)" + +[[tests.poetry-install]] +name = "poetry 2.x bullet syntax short-circuits to ok" +input = """ +• Installing requests (2.31.0) +• Installing certifi (2023.11.17) + +No changes. +""" +expected = "ok (up to date)" + +[[tests.poetry-install]] +name = "install strips download lines" +input = """ +Installing dependencies from lock file + + - Downloading requests-2.31.0-py3-none-any.whl (62.6 kB) + - Installing certifi (2023.11.17) + - Installing charset-normalizer (3.3.2) + - Installing idna (3.6) + - Installing urllib3 (2.1.0) + - Installing requests (2.31.0) + +Writing lock file +""" +expected = "Installing dependencies from lock file\nWriting lock file\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/quarto-render.toml b/crates/pi-natives/src/shell/minimizer/defs/quarto-render.toml new file mode 100644 index 000000000..9e10dcd57 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/quarto-render.toml @@ -0,0 +1,45 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/quarto-render.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.quarto-render] +description = "Compact quarto render output" +match_command = "^quarto$" +match_subcommand = "^render$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*processing file:", + "^\\s*\\d+/\\d+\\s", + "^\\s*running", + "^\\s*Rendering", + "^pandoc ", + "^ Validating", + "^ Resolving", +] +match_output = [ + { pattern = "Output created:", message = "ok (output created)" }, +] +max_lines = 20 + +[[tests.quarto-render]] +name = "success short-circuits to ok" +input = """ +processing file: index.qmd + Validating schema + Resolving resources +pandoc to html5 +Output created: _site/index.html +""" +expected = "ok (output created)" + +[[tests.quarto-render]] +name = "error passes through" +input = """ +processing file: broken.qmd + Validating schema +ERROR: Render failed + +caused by: + syntax error at line 10 +""" +expected = "ERROR: Render failed\ncaused by:\n syntax error at line 10\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/shellcheck.toml b/crates/pi-natives/src/shell/minimizer/defs/shellcheck.toml new file mode 100644 index 000000000..c3bf2727a --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/shellcheck.toml @@ -0,0 +1,30 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/shellcheck.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.shellcheck] +description = "Compact shellcheck output — strip blank lines, keep caret indicators for error position" +match_command = "^shellcheck$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", +] +max_lines = 50 + +[[tests.shellcheck]] +name = "multi-warning output stripped of blank lines only" +input = """ +In script.sh line 3: +if [[ $1 == "" ]] + ^-- SC2236: Use -z instead of ! -n. + +In script.sh line 7: +echo $var + ^-- SC2086: Double quote to prevent globbing. + +""" +expected = "In script.sh line 3:\nif [[ $1 == \"\" ]]\n ^-- SC2236: Use -z instead of ! -n.\nIn script.sh line 7:\necho $var\n ^-- SC2086: Double quote to prevent globbing.\n" + +[[tests.shellcheck]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/stat.toml b/crates/pi-natives/src/shell/minimizer/defs/stat.toml new file mode 100644 index 000000000..c798f158a --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/stat.toml @@ -0,0 +1,47 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/stat.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.stat] +description = "Compact stat output — strip device/inode/birth noise" +match_command = "^stat$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*Device:", + "^\\s*Birth:", +] +truncate_lines_at = 120 +max_lines = 20 + +[[tests.stat]] +name = "linux stat output strips device and birth" +input = """ + File: main.rs + Size: 12345 Blocks: 24 IO Block: 4096 regular file +Device: 801h/2049d Inode: 1234567 Links: 1 +Access: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick) +Access: 2026-03-10 12:00:00.000000000 +0100 +Modify: 2026-03-10 11:00:00.000000000 +0100 +Change: 2026-03-10 11:00:00.000000000 +0100 + Birth: 2026-03-09 10:00:00.000000000 +0100 +""" +expected = " File: main.rs\n Size: 12345 Blocks: 24 IO Block: 4096 regular file\nAccess: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)\nAccess: 2026-03-10 12:00:00.000000000 +0100\nModify: 2026-03-10 11:00:00.000000000 +0100\nChange: 2026-03-10 11:00:00.000000000 +0100\n" + +[[tests.stat]] +name = "macOS stat -x strips device and birth" +input = """ + File: "main.rs" + Size: 82848 FileType: Regular File + Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff) +Device: 1,15 Inode: 66302332 Links: 1 +Access: Wed Mar 18 21:21:15 2026 +Modify: Wed Mar 18 20:56:11 2026 +Change: Wed Mar 18 20:56:11 2026 + Birth: Wed Mar 18 20:56:11 2026 +""" +expected = " File: \"main.rs\"\n Size: 82848 FileType: Regular File\n Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff)\nAccess: Wed Mar 18 21:21:15 2026\nModify: Wed Mar 18 20:56:11 2026\nChange: Wed Mar 18 20:56:11 2026\n" + +[[tests.stat]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/systemctl-status.toml b/crates/pi-natives/src/shell/minimizer/defs/systemctl-status.toml new file mode 100644 index 000000000..f6cb14e93 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/systemctl-status.toml @@ -0,0 +1,37 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/systemctl-status.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.systemctl-status] +description = "Compact systemctl status output — strip blank lines, limit to 20 lines" +match_command = "^systemctl$" +match_subcommand = "^status$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", +] +max_lines = 20 + +[[tests.systemctl-status]] +name = "verbose unit status stripped of blank lines" +input = """ +● nginx.service - A high performance web server + Loaded: loaded (/lib/systemd/system/nginx.service; enabled) + Active: active (running) since Mon 2024-01-15 10:30:00 UTC; 2h ago + Docs: man:nginx(8) + Main PID: 1234 (nginx) + Tasks: 3 (limit: 4915) + Memory: 8.5M + + CGroup: /system.slice/nginx.service + ├─1234 nginx: master process /usr/sbin/nginx + └─1235 nginx: worker process + +Jan 15 10:30:00 host nginx[1234]: nginx/1.24.0 +Jan 15 10:30:00 host systemd[1]: Started nginx.service +""" +expected = "● nginx.service - A high performance web server\n Loaded: loaded (/lib/systemd/system/nginx.service; enabled)\n Active: active (running) since Mon 2024-01-15 10:30:00 UTC; 2h ago\n Docs: man:nginx(8)\n Main PID: 1234 (nginx)\n Tasks: 3 (limit: 4915)\n Memory: 8.5M\n CGroup: /system.slice/nginx.service\n ├─1234 nginx: master process /usr/sbin/nginx\n └─1235 nginx: worker process\nJan 15 10:30:00 host nginx[1234]: nginx/1.24.0\nJan 15 10:30:00 host systemd[1]: Started nginx.service\n" + +[[tests.systemctl-status]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/defs/terraform-plan.toml b/crates/pi-natives/src/shell/minimizer/defs/terraform-plan.toml new file mode 100644 index 000000000..d6f70995b --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/terraform-plan.toml @@ -0,0 +1,39 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/terraform-plan.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.terraform-plan] +description = "Compact Terraform plan output" +match_command = "^terraform$" +match_subcommand = "^plan$" +strip_ansi = true +strip_lines_matching = [ + "^Refreshing state", + "^\\s*#.*unchanged", + "^\\s*$", + "^Acquiring state lock", + "^Releasing state lock", +] +max_lines = 80 +on_empty = "terraform plan: no changes detected" + +[[tests.terraform-plan]] +name = "strips Refreshing state lines and blank lines" +input = """ +Acquiring state lock. This may take a few moments... +Refreshing state... [id=vpc-abc] +Refreshing state... [id=sg-123] +Releasing state lock. This may take a few moments... + +Terraform will perform the following actions: + + # aws_instance.web will be created + + resource "aws_instance" "web" {} + +Plan: 1 to add, 0 to change, 0 to destroy. +""" +expected = "Terraform will perform the following actions:\n # aws_instance.web will be created\n + resource \"aws_instance\" \"web\" {}\nPlan: 1 to add, 0 to change, 0 to destroy.\n" + +[[tests.terraform-plan]] +name = "strips noise, preserves non-blank content" +input = "Refreshing state... [id=vpc-abc]\nNo changes. Your infrastructure matches the configuration." +expected = "No changes. Your infrastructure matches the configuration.\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-fmt.toml b/crates/pi-natives/src/shell/minimizer/defs/tofu-fmt.toml new file mode 100644 index 000000000..346e8e40f --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/tofu-fmt.toml @@ -0,0 +1,20 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-fmt.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.tofu-fmt] +description = "Compact OpenTofu fmt output" +match_command = "^tofu$" +match_subcommand = "^fmt$" +strip_ansi = true +on_empty = "tofu fmt: ok (no changes)" +max_lines = 30 + +[[tests.tofu-fmt]] +name = "empty output returns on_empty message" +input = "" +expected = "tofu fmt: ok (no changes)" + +[[tests.tofu-fmt]] +name = "changed files pass through" +input = "main.tf\nvariables.tf" +expected = "main.tf\nvariables.tf" diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-init.toml b/crates/pi-natives/src/shell/minimizer/defs/tofu-init.toml new file mode 100644 index 000000000..ffe59e4a8 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/tofu-init.toml @@ -0,0 +1,42 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-init.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.tofu-init] +description = "Compact OpenTofu init output" +match_command = "^tofu$" +match_subcommand = "^init$" +strip_ansi = true +strip_lines_matching = [ + "^- Downloading", + "^- Installing", + "^- Using previously-installed", + "^\\s*$", + "^Initializing provider", + "^Initializing the backend", + "^Initializing modules", +] +max_lines = 20 +on_empty = "tofu init: ok" + +[[tests.tofu-init]] +name = "strips downloading/installing lines" +input = """ +Initializing the backend... +Initializing provider plugins... +- Downloading hashicorp/aws 5.0.0... +- Installing hashicorp/aws 5.0.0... +- Using previously-installed hashicorp/random 3.5.1 + +OpenTofu has been successfully initialized! +""" +expected = "OpenTofu has been successfully initialized!\n" + +[[tests.tofu-init]] +name = "on_empty when all noise stripped" +input = """ +Initializing the backend... +Initializing provider plugins... +- Using previously-installed hashicorp/aws 5.0.0 + +""" +expected = "tofu init: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-plan.toml b/crates/pi-natives/src/shell/minimizer/defs/tofu-plan.toml new file mode 100644 index 000000000..3dd9ef340 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/tofu-plan.toml @@ -0,0 +1,39 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-plan.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.tofu-plan] +description = "Compact OpenTofu plan output" +match_command = "^tofu$" +match_subcommand = "^plan$" +strip_ansi = true +strip_lines_matching = [ + "^Refreshing state", + "^\\s*#.*unchanged", + "^\\s*$", + "^Acquiring state lock", + "^Releasing state lock", +] +max_lines = 80 +on_empty = "tofu plan: no changes detected" + +[[tests.tofu-plan]] +name = "strips Refreshing state and lock lines" +input = """ +Acquiring state lock. This may take a few moments... +Refreshing state... [id=vpc-abc123] +Refreshing state... [id=sg-def456] +Releasing state lock. This may take a few moments... + +OpenTofu will perform the following actions: + + # aws_instance.web will be created + + resource "aws_instance" "web" {} + +Plan: 1 to add, 0 to change, 0 to destroy. +""" +expected = "OpenTofu will perform the following actions:\n # aws_instance.web will be created\n + resource \"aws_instance\" \"web\" {}\nPlan: 1 to add, 0 to change, 0 to destroy.\n" + +[[tests.tofu-plan]] +name = "on_empty when all noise stripped" +input = "Refreshing state... [id=vpc-abc]\nAcquiring state lock. This may take a few moments...\nReleasing state lock. This may take a few moments..." +expected = "tofu plan: no changes detected" diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-validate.toml b/crates/pi-natives/src/shell/minimizer/defs/tofu-validate.toml new file mode 100644 index 000000000..397e36010 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/tofu-validate.toml @@ -0,0 +1,21 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-validate.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.tofu-validate] +description = "Compact OpenTofu validate output" +match_command = "^tofu$" +match_subcommand = "^validate$" +strip_ansi = true +match_output = [ + { pattern = "Success! The configuration is valid", message = "ok (valid)" }, +] + +[[tests.tofu-validate]] +name = "success short-circuits to ok" +input = "Success! The configuration is valid." +expected = "ok (valid)" + +[[tests.tofu-validate]] +name = "error passes through unchanged" +input = "Error: Invalid resource type\n on main.tf line 3: resource \"aws_instancee\" \"web\"" +expected = "Error: Invalid resource type\n on main.tf line 3: resource \"aws_instancee\" \"web\"" diff --git a/crates/pi-natives/src/shell/minimizer/defs/trunk-build.toml b/crates/pi-natives/src/shell/minimizer/defs/trunk-build.toml new file mode 100644 index 000000000..2a56e9f6f --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/trunk-build.toml @@ -0,0 +1,43 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/trunk-build.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.trunk-build] +description = "Compact trunk build output" +match_command = "^trunk$" +match_subcommand = "^build$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*Compiling\\s", + "^\\s*Downloading\\s", + "^\\s*Fetching\\s", + "^\\s*Fresh\\s", + "^\\s*Checking\\s", +] +tail_lines = 10 +max_lines = 30 +on_empty = "trunk build: ok" + +[[tests.trunk-build]] +name = "strips compile noise, keeps tail summary" +input = """ + Compiling tokio v1.35.0 + Compiling hyper v0.14.28 + Compiling my-crate v0.1.0 + Downloading serde v1.0.195 + Fresh regex v1.10.2 + + Finished release [optimized] target(s) in 45.23s + Binary: target/release/my-crate (5.2MB) +""" +expected = " Finished release [optimized] target(s) in 45.23s\n Binary: target/release/my-crate (5.2MB)\n" + +[[tests.trunk-build]] +name = "on_empty when all noise stripped" +input = """ + Compiling my-crate v0.1.0 + Fresh serde v1.0 + Checking tokio v1.35.0 + +""" +expected = "trunk build: ok" diff --git a/crates/pi-natives/src/shell/minimizer/defs/uv-sync.toml b/crates/pi-natives/src/shell/minimizer/defs/uv-sync.toml new file mode 100644 index 000000000..62e8e48e7 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/uv-sync.toml @@ -0,0 +1,41 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/uv-sync.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.uv-sync] +description = "Compact uv sync/pip install output — strip downloads, short-circuit when up-to-date" +match_command = "^uv$" +match_subcommand = "^(sync|pip)$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s+Downloading ", + "^\\s+Using cached ", + "^\\s+Preparing ", +] +match_output = [ + { pattern = "Audited \\d+ package", message = "ok (up to date)" }, +] +max_lines = 20 + +[[tests.uv-sync]] +name = "audited packages short-circuits to ok" +input = """ +Resolved 42 packages in 123ms +Audited 42 packages in 0.05ms +""" +expected = "ok (up to date)" + +[[tests.uv-sync]] +name = "install strips download and cached lines" +input = """ + Downloading requests-2.31.0-py3-none-any.whl (62.6 kB) + Using cached certifi-2023.11.17-py3-none-any.whl (162 kB) + Preparing packages... +Installed 5 packages in 23ms + + certifi==2023.11.17 + + charset-normalizer==3.3.2 + + idna==3.6 + + requests==2.31.0 + + urllib3==2.1.0 +""" +expected = "Installed 5 packages in 23ms\n + certifi==2023.11.17\n + charset-normalizer==3.3.2\n + idna==3.6\n + requests==2.31.0\n + urllib3==2.1.0\n" diff --git a/crates/pi-natives/src/shell/minimizer/defs/yamllint.toml b/crates/pi-natives/src/shell/minimizer/defs/yamllint.toml new file mode 100644 index 000000000..14bc9e539 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/defs/yamllint.toml @@ -0,0 +1,28 @@ +# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/yamllint.toml +# match_command adapted for the local (program, subcommand) dispatch model. + +[filters.yamllint] +description = "Compact yamllint output — strip blank lines, limit rows" +match_command = "^yamllint$" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", +] +max_lines = 50 +truncate_lines_at = 120 + +[[tests.yamllint]] +name = "multi-warning output stripped of blank lines" +input = """ +config.yml + 3:1 warning missing document start "---" (document-start) + 5:12 error too many spaces inside braces (braces) + + 8:1 error wrong indentation: expected 2 but found 4 (indentation) +""" +expected = "config.yml\n 3:1 warning missing document start \"---\" (document-start)\n 5:12 error too many spaces inside braces (braces)\n 8:1 error wrong indentation: expected 2 but found 4 (indentation)\n" + +[[tests.yamllint]] +name = "empty input passes through" +input = "" +expected = "" diff --git a/crates/pi-natives/src/shell/minimizer/engine.rs b/crates/pi-natives/src/shell/minimizer/engine.rs index 8cb96ad48..b2a9a13ee 100644 --- a/crates/pi-natives/src/shell/minimizer/engine.rs +++ b/crates/pi-natives/src/shell/minimizer/engine.rs @@ -414,7 +414,7 @@ mod tests { assert!(should_minimize("git status", &cfg)); let out = apply("git status", "## main\n M file.rs\n", 0, &cfg); assert!(out.changed); - assert!(out.text.contains("unstaged: 1")); + assert!(out.text.contains("modified: 1")); } #[test] @@ -458,7 +458,7 @@ mod tests { assert_eq!(out.filter, "compound"); assert_eq!(out.original_text.as_deref(), Some("before\n## main\n M file.rs\nafter\n")); assert!(out.text.contains("before\n")); - assert!(out.text.contains("unstaged: 1")); + assert!(out.text.contains("modified: 1")); assert!(out.text.contains("after\n")); } diff --git a/crates/pi-natives/src/shell/minimizer/filters/cargo.rs b/crates/pi-natives/src/shell/minimizer/filters/cargo.rs index 3da5a8b15..812690bff 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/cargo.rs +++ b/crates/pi-natives/src/shell/minimizer/filters/cargo.rs @@ -61,7 +61,7 @@ fn is_compiling_noise(line: &str) -> bool { fn failures_only(input: &str, exit_code: i32) -> String { if exit_code == 0 { - return strip_passing_tests(input); + return summarize_successful_test_run(input); } let mut out = String::new(); let mut keep = false; @@ -89,6 +89,119 @@ fn failures_only(input: &str, exit_code: i32) -> String { } } +#[derive(Default)] +struct CargoTestTotals { + suites: usize, + passed: u64, + failed: u64, + ignored: u64, + measured: u64, + filtered: u64, + warnings: u64, + duration: Option, +} + +fn summarize_successful_test_run(input: &str) -> String { + let mut totals = CargoTestTotals::default(); + + for line in input.lines() { + let trimmed = line.trim(); + if let Some(summary) = trimmed.strip_prefix("test result: ok.") { + totals.suites += 1; + collect_cargo_test_summary(summary, &mut totals); + continue; + } + if let Some(warnings) = parse_generated_warning_count(trimmed) { + totals.warnings += warnings; + } + } + + if totals.suites == 0 { + return strip_passing_tests(input); + } + + let mut out = String::from("cargo test:"); + if totals.passed > 0 { + out.push(' '); + out.push_str(&totals.passed.to_string()); + out.push_str(" passed"); + } else { + out.push_str(" ok"); + } + + let mut details = Vec::new(); + details.push(format_suite_count(totals.suites)); + if totals.failed > 0 { + details.push(format!("{} failed", totals.failed)); + } + if totals.ignored > 0 { + details.push(format!("{} ignored", totals.ignored)); + } + if totals.measured > 0 { + details.push(format!("{} measured", totals.measured)); + } + if totals.filtered > 0 { + details.push(format!("{} filtered", totals.filtered)); + } + if totals.warnings > 0 { + details.push(format!("{} warnings", totals.warnings)); + } + if let Some(duration) = totals.duration { + details.push(duration); + } + if !details.is_empty() { + out.push_str(" ("); + out.push_str(&details.join(", ")); + out.push(')'); + } + out.push('\n'); + out +} + +fn collect_cargo_test_summary(summary: &str, totals: &mut CargoTestTotals) { + for part in summary.split(';') { + let trimmed = part.trim().trim_end_matches('.'); + if let Some(value) = parse_count_prefix(trimmed, "passed") { + totals.passed += value; + } else if let Some(value) = parse_count_prefix(trimmed, "failed") { + totals.failed += value; + } else if let Some(value) = parse_count_prefix(trimmed, "ignored") { + totals.ignored += value; + } else if let Some(value) = parse_count_prefix(trimmed, "measured") { + totals.measured += value; + } else if let Some(value) = parse_count_prefix(trimmed, "filtered out") { + totals.filtered += value; + } else if let Some(duration) = trimmed.strip_prefix("finished in ") { + totals.duration = Some(duration.to_string()); + } + } +} + +fn parse_generated_warning_count(line: &str) -> Option { + if !line.contains(" generated ") || !line.ends_with(" warnings") { + return None; + } + let before = line.rsplit_once(" warnings")?.0; + let count_text = before.rsplit_once(' ')?.1; + count_text.parse().ok() +} + +fn parse_count_prefix(text: &str, label: &str) -> Option { + let (count, rest) = text.split_once(' ')?; + if rest != label { + return None; + } + count.parse().ok() +} + +fn format_suite_count(suites: usize) -> String { + if suites == 1 { + "1 suite".to_string() + } else { + format!("{suites} suites") + } +} + fn strip_passing_tests(input: &str) -> String { let mut out = String::new(); for line in input.lines() { @@ -203,6 +316,15 @@ mod tests { assert_eq!(out, "running 2 tests\ntest result: ok\n"); } + #[test] + fn summarizes_successful_cargo_test_run() { + let input = "warning: unused variable: `start`\nwarning: `rtk` (bin \"rtk\" test) generated \ + 17 warnings\nrunning 262 tests\ntest a ... ok\ntest b ... ok\ntest result: ok. \ + 262 passed; 0 failed; 0 ignored; 0 measured\n"; + let out = summarize_successful_test_run(input); + assert_eq!(out, "cargo test: 262 passed (1 suite, 17 warnings)\n"); + } + #[test] fn supports_nextest_and_keeps_failures_with_summary() { assert!(supports(Some("nextest"))); diff --git a/crates/pi-natives/src/shell/minimizer/filters/git.rs b/crates/pi-natives/src/shell/minimizer/filters/git.rs index 2032b34d1..5ca4e7280 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/git.rs +++ b/crates/pi-natives/src/shell/minimizer/filters/git.rs @@ -1,6 +1,6 @@ //! Git output filters. -use std::collections::{BTreeMap, btree_map::Entry}; +use std::collections::BTreeMap; use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; @@ -37,7 +37,7 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> Minimizer let cleaned = primitives::strip_ansi(input); let text = match ctx.subcommand { Some("status") => condense_status(&cleaned), - Some("diff") => cleaned, + Some("diff") => condense_diff(&cleaned), Some("show") => primitives::head_tail_lines(&cleaned, 80, 40), Some("log") => condense_log(&cleaned, 32, 16), Some("branch" | "stash" | "tag") => primitives::compact_listing(&cleaned, 40), @@ -55,17 +55,6 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> Minimizer } } -#[derive(Default)] -struct StatusTreeNode { - children: BTreeMap, - leaves: Vec, -} - -struct StatusLeaf { - status: String, - name: String, -} - struct StatusEntry { status: String, path: String, @@ -125,24 +114,42 @@ fn condense_status(input: &str) -> String { } let mut counts = StatusCounts::default(); - let mut tree = StatusTreeNode::default(); for entry in &entries { count_status(&entry.status, &mut counts); - insert_status_path(&mut tree, &entry.status, &entry.path); } - let mut out = String::from("git status summary"); + let mut groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for entry in &entries { + groups + .entry(status_group_label(&entry.status)) + .or_default() + .push(&entry.path); + } + + let mut out = String::from("git status"); if let Some(branch) = branch { - out.push_str(" on "); + out.push_str(": "); out.push_str(branch); } out.push('\n'); push_count(&mut out, "staged", counts.staged); - push_count(&mut out, "unstaged", counts.unstaged); + push_count(&mut out, "modified", counts.unstaged); push_count(&mut out, "untracked", counts.untracked); push_count(&mut out, "conflicts", counts.conflicts); - out.push_str("paths:\n"); - render_status_tree(&tree, &mut out, 0); + for (label, paths) in groups { + out.push_str(label); + out.push_str(":\n"); + for path in paths.iter().take(24) { + out.push_str(" "); + out.push_str(path); + out.push('\n'); + } + if paths.len() > 24 { + out.push_str(" … "); + out.push_str(&(paths.len() - 24).to_string()); + out.push_str(" more\n"); + } + } out } @@ -208,49 +215,21 @@ fn count_status(status: &str, counts: &mut StatusCounts) { } } -fn insert_status_path(root: &mut StatusTreeNode, status: &str, path: &str) { - let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); - insert_status_parts(root, status, &parts); -} - -fn insert_status_parts(node: &mut StatusTreeNode, status: &str, parts: &[&str]) { - if parts.is_empty() { - return; +fn status_group_label(status: &str) -> &'static str { + if status == "??" { + return "Untracked"; } - if parts.len() == 1 { - node - .leaves - .push(StatusLeaf { status: status.to_string(), name: parts[0].to_string() }); - return; + if status.contains('U') { + return "Conflicts"; } - let child = match node.children.entry(parts[0].to_string()) { - Entry::Occupied(entry) => entry.into_mut(), - Entry::Vacant(entry) => entry.insert(StatusTreeNode::default()), - }; - insert_status_parts(child, status, &parts[1..]); -} - -fn render_status_tree(node: &StatusTreeNode, out: &mut String, depth: usize) { - for (name, child) in &node.children { - push_status_indent(out, depth); - out.push_str(name); - out.push_str("/\n"); - render_status_tree(child, out, depth + 1); + let mut chars = status.chars(); + if matches!(chars.next(), Some('M' | 'A' | 'D' | 'R' | 'C')) { + return "Staged"; } - for leaf in &node.leaves { - push_status_indent(out, depth); - out.push('['); - out.push_str(&leaf.status); - out.push_str("] "); - out.push_str(&leaf.name); - out.push('\n'); - } -} - -fn push_status_indent(out: &mut String, depth: usize) { - for _ in 0..depth { - out.push_str(" "); + if matches!(chars.next(), Some('M' | 'D')) { + return "Modified"; } + "Changed" } fn push_count(out: &mut String, label: &str, count: usize) { @@ -299,6 +278,27 @@ fn has_token(command: &str, token: &str) -> bool { } fn condense_log(input: &str, head: usize, tail: usize) -> String { + let entries = parse_log_entries(input); + if !entries.is_empty() { + let mut out = String::new(); + if entries.len() <= head + tail { + for entry in &entries { + push_log_entry(&mut out, entry); + } + } else { + for entry in entries.iter().take(head) { + push_log_entry(&mut out, entry); + } + out.push_str("… "); + out.push_str(&(entries.len() - head - tail).to_string()); + out.push_str(" commits omitted …\n"); + for entry in entries.iter().skip(entries.len() - tail) { + push_log_entry(&mut out, entry); + } + } + return out; + } + let mut out = String::new(); for line in input.lines() { if let Some(commit) = line.strip_prefix("commit ") { @@ -319,6 +319,240 @@ fn condense_log(input: &str, head: usize, tail: usize) -> String { primitives::head_tail_lines(&out, head, tail) } +struct LogEntry { + hash: String, + subject: String, +} + +fn push_log_entry(out: &mut String, entry: &LogEntry) { + out.push_str(&entry.hash); + if !entry.subject.is_empty() { + out.push(' '); + out.push_str(&entry.subject); + } + out.push('\n'); +} + +fn parse_log_entries(input: &str) -> Vec { + let mut entries = Vec::new(); + let mut current: Option = None; + + for line in input.lines() { + if let Some(rest) = line.strip_prefix("commit ") { + if let Some(entry) = current.take() { + entries.push(entry); + } + let trimmed = rest.trim(); + let (hash, subject) = trimmed + .split_once(' ') + .map_or((trimmed, ""), |(hash, subject)| (hash, subject.trim())); + current = Some(LogEntry { hash: short_hash(hash), subject: subject.to_string() }); + continue; + } + + let Some(entry) = current.as_mut() else { + continue; + }; + if !entry.subject.is_empty() { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.starts_with("Author:") + || trimmed.starts_with("Date:") + || trimmed.starts_with("Merge:") + || trimmed.contains('|') + || trimmed.contains("files changed") + || trimmed.contains("file changed") + { + continue; + } + entry.subject = trimmed.to_string(); + } + + if let Some(entry) = current { + entries.push(entry); + } + entries +} + +fn short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + +struct DiffFile { + path: String, + added: usize, + removed: usize, + hunks: Vec, +} + +struct DiffHunk { + header: String, + lines: Vec, +} + +fn condense_diff(input: &str) -> String { + let files = parse_unified_diff(input); + if files.is_empty() { + return input.to_string(); + } + + let total_added: usize = files.iter().map(|file| file.added).sum(); + let total_removed: usize = files.iter().map(|file| file.removed).sum(); + if total_added == 0 && total_removed == 0 { + return input.to_string(); + } + + let mut out = String::new(); + for file in files.iter().take(20) { + let changed = file.added + file.removed; + out.push_str(&file.path); + out.push_str(" | "); + out.push_str(&changed.to_string()); + out.push(' '); + out.push_str(&diff_bar(file.added, file.removed)); + out.push('\n'); + } + if files.len() > 20 { + out.push_str("… "); + out.push_str(&(files.len() - 20).to_string()); + out.push_str(" files omitted from stat\n"); + } + out.push_str(&format_file_count(files.len())); + out.push_str(" changed, "); + out.push_str(&total_added.to_string()); + out.push_str(" insertions(+), "); + out.push_str(&total_removed.to_string()); + out.push_str(" deletions(-)\n\n--- Changes ---\n"); + + for file in files.iter().take(12) { + out.push('\n'); + out.push_str("File: "); + out.push_str(&file.path); + out.push('\n'); + for hunk in file.hunks.iter().take(8) { + out.push_str(" "); + out.push_str(&hunk.header); + out.push('\n'); + for line in hunk.lines.iter().take(6) { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + if hunk.lines.len() > 6 { + out.push_str(" … "); + out.push_str(&(hunk.lines.len() - 6).to_string()); + out.push_str(" changed lines omitted\n"); + } + } + if file.hunks.len() > 8 { + out.push_str(" … "); + out.push_str(&(file.hunks.len() - 8).to_string()); + out.push_str(" hunks omitted\n"); + } + } + if files.len() > 12 { + out.push_str("\n… "); + out.push_str(&(files.len() - 12).to_string()); + out.push_str(" files omitted from changes\n"); + } + out +} + +fn parse_unified_diff(input: &str) -> Vec { + let mut files = Vec::new(); + let mut current: Option = None; + let mut current_hunk: Option = None; + + for line in input.lines() { + if let Some(path) = parse_diff_git_path(line) { + flush_hunk(&mut current, &mut current_hunk); + if let Some(file) = current.take() { + files.push(file); + } + current = Some(DiffFile { path, added: 0, removed: 0, hunks: Vec::new() }); + continue; + } + if let Some(path) = line.strip_prefix("+++ b/") { + if let Some(file) = current.as_mut() { + file.path = path.to_string(); + } + continue; + } + if line.starts_with("@@") { + flush_hunk(&mut current, &mut current_hunk); + current_hunk = Some(DiffHunk { header: line.to_string(), lines: Vec::new() }); + continue; + } + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + let Some(file) = current.as_mut() else { + continue; + }; + if line.starts_with('+') { + file.added += 1; + push_diff_line(&mut current_hunk, line); + } else if line.starts_with('-') { + file.removed += 1; + push_diff_line(&mut current_hunk, line); + } + } + + flush_hunk(&mut current, &mut current_hunk); + if let Some(file) = current { + files.push(file); + } + files + .into_iter() + .filter(|file| file.added > 0 || file.removed > 0) + .collect() +} + +fn parse_diff_git_path(line: &str) -> Option { + let rest = line.strip_prefix("diff --git ")?; + let mut parts = rest.split_whitespace(); + let _old = parts.next()?; + let new = parts.next()?; + Some(new.strip_prefix("b/").map_or(new, |path| path).to_string()) +} + +fn flush_hunk(file: &mut Option, hunk: &mut Option) { + let Some(hunk) = hunk.take() else { + return; + }; + if let Some(file) = file.as_mut() { + file.hunks.push(hunk); + } +} + +fn push_diff_line(hunk: &mut Option, line: &str) { + let Some(hunk) = hunk.as_mut() else { + return; + }; + hunk.lines.push(primitives::truncate_line(line, 160)); +} + +fn diff_bar(added: usize, removed: usize) -> String { + let total = added + removed; + if total == 0 { + return String::new(); + } + let width = total.clamp(1, 24); + let plus = (added * width).div_ceil(total); + let minus = width.saturating_sub(plus); + format!("{}{}", "+".repeat(plus), "-".repeat(minus)) +} + +fn format_file_count(files: usize) -> String { + if files == 1 { + "1 file".to_string() + } else { + format!("{files} files") + } +} + fn condense_noisy_output(input: &str) -> String { let deduped = primitives::dedup_consecutive_lines(input); primitives::head_tail_lines(&deduped, 80, 40) @@ -338,7 +572,7 @@ mod tests { } #[test] - fn status_compacts_paths_into_tree() { + fn status_compacts_paths_into_groups() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("status"), "git status --short", &cfg); let input = "## main\n M packages/agent/lib/agent.ts\n M \ @@ -346,16 +580,21 @@ mod tests { crates/pi-natives/src/shell/minimizer/filters/git.rs\n"; let out = filter(&ctx, input, 0); assert!(out.changed); - assert!(out.text.contains("git status summary on main")); - assert!(out.text.contains("unstaged: 2")); + assert!(out.text.contains("git status: main")); + assert!(out.text.contains("modified: 2")); assert!(out.text.contains("untracked: 1")); - assert!(out.text.contains("packages/\n agent/\n")); - assert!(out.text.contains(" lib/\n [ M] agent.ts\n")); assert!( out.text - .contains(" tests/\n [ M] session-restore.test.ts\n") + .contains("Modified:\n packages/agent/lib/agent.ts") + ); + assert!( + out.text + .contains(" packages/agent/tests/session-restore.test.ts") + ); + assert!( + out.text + .contains("Untracked:\n crates/pi-natives/src/shell/minimizer/filters/git.rs") ); - assert!(out.text.contains("crates/\n pi-natives/\n")); } #[test] @@ -419,7 +658,7 @@ mod tests { } #[test] - fn log_is_head_tail_truncated_after_metadata_removal() { + fn log_is_compacted_to_short_hashes_and_subjects() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log", &cfg); let mut input = String::new(); @@ -433,10 +672,52 @@ mod tests { input.push('\n'); } let out = filter(&ctx, &input, 0); - assert!(out.text.contains("… ")); - assert!(out.text.contains("message 0")); - assert!(out.text.contains("message 69")); + assert!(out.text.contains("… 22 commits omitted …")); + assert!(out.text.contains("abcdef1 message 0")); + assert!(!out.text.contains("message 47")); + assert!(out.text.contains("abcdef1 message 69")); assert!(!out.text.contains("Author:")); assert!(!out.text.contains("Date:")); } + + #[test] + fn log_supports_subject_on_commit_line() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("log"), "git log --stat -10", &cfg); + let input = "commit c84fa3c fix: add website URL (rtk-ai.app)\nAuthor: Somebody\nDate: \ + today\n\n README.md | 8 ++++++++\n 1 file changed, 8 insertions(+)\n"; + let out = filter(&ctx, input, 0); + assert_eq!(out.text, "c84fa3c fix: add website URL (rtk-ai.app)\n"); + } + + #[test] + fn diff_condenses_unified_patch_to_stat_and_hunk_samples() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("diff"), "git diff HEAD~1", &cfg); + let input = "diff --git a/index.html b/index.html\nindex 1b7488b..0ebac4f 100644\n--- \ + a/index.html\n+++ b/index.html\n@@ -629,7 +629,7 @@\n width: 100%;\n- \ + min-width: 800px;\n+ min-width: 1050px;\n@@ -1051,6 +1051,4 @@\n+ /* \ + === Share My Gain === */\n+ .share-gain { background: var(--bg); \ + }\n-old\n+new\n"; + let out = filter(&ctx, input, 0); + assert!(out.changed); + assert!(out.text.contains("index.html | 6 "), "{}", out.text); + assert!( + out.text + .contains("1 file changed, 4 insertions(+), 2 deletions(-)") + ); + assert!(out.text.contains("--- Changes ---")); + assert!(out.text.contains("@@ -629,7 +629,7 @@")); + assert!(out.text.contains("- min-width: 800px;")); + assert!(out.text.contains("+ min-width: 1050px;")); + } + + #[test] + fn legacy_log_fallback_removes_metadata_when_no_commit_records_parse() { + let input = "commitish output\nAuthor: Somebody \nDate: today\nmessage 0\n"; + let out = condense_log(input, 32, 16); + assert!(out.contains("message 0")); + assert!(!out.contains("Author:")); + assert!(!out.contains("Date:")); + } } diff --git a/crates/pi-natives/src/shell/minimizer/filters/go.rs b/crates/pi-natives/src/shell/minimizer/filters/go.rs index 4eb085e3b..57a3bd35b 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/go.rs +++ b/crates/pi-natives/src/shell/minimizer/filters/go.rs @@ -123,6 +123,14 @@ fn should_keep_go_test_line(line: &str, exit_code: i32) -> bool { let trimmed = line.trim(); let lower = trimmed.to_ascii_lowercase(); + if exit_code == 0 { + return trimmed.starts_with("--- PASS") + || trimmed.starts_with("--- SKIP") + || lower.starts_with("ok\t") + || lower.starts_with("ok ") + || lower.starts_with("?\t"); + } + trimmed.starts_with("FAIL") || trimmed.starts_with("--- FAIL") || trimmed.starts_with("panic:") @@ -137,6 +145,7 @@ fn should_keep_go_test_line(line: &str, exit_code: i32) -> bool { || lower.contains("assert") || lower.contains("killed with quit") || lower.starts_with("ok\t") + || lower.starts_with("ok ") || lower.starts_with("?\t") || exit_code != 0 && (lower.contains("timeout") || lower.contains("signal")) } @@ -363,6 +372,27 @@ mod tests { assert!(out.text.contains("Test killed with quit")); } + #[test] + fn go_test_verbose_success_drops_run_and_ginkgo_success_noise() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = MinimizerCtx { + program: "go", + subcommand: Some("test"), + command: "go test ./... -v", + config: &cfg, + }; + let input = "=== RUN TestControllers\nRunning Suite: Controller Suite\nSUCCESS! -- 1 \ + Passed | 0 Failed | 0 Pending\n--- PASS: TestControllers (6.04s)\nPASS\nok \ + kubecraft.ai/.../controller 6.610s\n=== RUN TestNewClient\n--- PASS: \ + TestNewClient (0.00s)\nPASS\nok kubecraft.ai/.../llm 0.776s\n"; + let out = filter(&ctx, input, 0); + assert!(out.text.contains("--- PASS: TestControllers (6.04s)")); + assert!(out.text.contains("ok kubecraft.ai/.../controller 6.610s")); + assert!(out.text.contains("--- PASS: TestNewClient (0.00s)")); + assert!(!out.text.contains("=== RUN")); + assert!(!out.text.contains("SUCCESS!")); + } + #[test] fn summarizes_golangci_json_issues() { let input = r#"{"Issues":[{"FromLinter":"govet","Text":"unreachable code","Pos":{"Filename":"main.go","Line":7,"Column":2}}]}"#; diff --git a/crates/pi-natives/src/shell/minimizer/filters/gt.rs b/crates/pi-natives/src/shell/minimizer/filters/gt.rs index 91e1d3dc2..069159d6e 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/gt.rs +++ b/crates/pi-natives/src/shell/minimizer/filters/gt.rs @@ -242,8 +242,8 @@ mod tests { let out = filter(&ctx, "## main\n M a.rs\n?? b.rs\n", 0); assert!(out.changed); - assert!(out.text.contains("git status summary on main")); - assert!(out.text.contains("unstaged: 1")); + assert!(out.text.contains("git status: main")); + assert!(out.text.contains("modified: 1")); assert!(out.text.contains("untracked: 1")); } diff --git a/crates/pi-natives/src/shell/minimizer/filters/listing.rs b/crates/pi-natives/src/shell/minimizer/filters/listing.rs index 3b486b667..226db4283 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/listing.rs +++ b/crates/pi-natives/src/shell/minimizer/filters/listing.rs @@ -1,5 +1,7 @@ //! Filesystem listing and search filters. +use std::{collections::BTreeMap, path::Path}; + use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { @@ -8,9 +10,11 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO cleaned } else { match ctx.program { - "grep" | "rg" => primitives::group_by_file(&cleaned, 12), - "ls" | "tree" | "find" => compact_listing_output(&cleaned), - "cat" | "read" => cleaned, + "grep" | "rg" => compact_grep_output(&cleaned), + "ls" => compact_ls_output(&cleaned).unwrap_or_else(|| compact_listing_output(&cleaned)), + "tree" => compact_listing_output(&cleaned), + "find" => compact_find_output(&cleaned), + "cat" | "read" => compact_cat_output(ctx, &cleaned), "stat" | "du" | "df" | "wc" => compact_summary_output(&cleaned), "jq" | "json" => cleaned, _ => cleaned, @@ -27,6 +31,661 @@ fn compact_listing_output(input: &str) -> String { primitives::compact_listing(input, 80) } +struct GrepMatch { + line_no: String, + text: String, +} + +fn compact_grep_output(input: &str) -> String { + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut ungrouped = Vec::new(); + + for line in input.lines() { + if let Some((file, line_no, text)) = split_grep_line(line) { + grouped + .entry(file.to_string()) + .or_default() + .push(GrepMatch { line_no: line_no.to_string(), text: collapse_match_text(text) }); + } else if !line.trim().is_empty() { + ungrouped.push(line.to_string()); + } + } + + let match_count: usize = grouped.values().map(Vec::len).sum(); + if grouped.is_empty() || match_count <= 12 && grouped.len() <= 3 { + return primitives::group_by_file(input, 12); + } + + let mut out = format!("grep: {match_count} matches in {} files\n", grouped.len()); + let mut shown_matches = 0usize; + let mut shown_files = 0usize; + for (file, matches) in &grouped { + if shown_files >= 12 { + break; + } + shown_files += 1; + out.push('\n'); + out.push_str(file); + out.push_str(":\n"); + for entry in matches.iter().take(4) { + shown_matches += 1; + out.push_str(" "); + out.push_str(&entry.line_no); + out.push_str(": "); + out.push_str(&entry.text); + out.push('\n'); + } + if matches.len() > 4 { + out.push_str(" … "); + out.push_str(&(matches.len() - 4).to_string()); + out.push_str(" more in file\n"); + } + } + + let omitted_files = grouped.len().saturating_sub(shown_files); + let omitted_matches = match_count.saturating_sub(shown_matches); + if omitted_files > 0 || omitted_matches > 0 { + out.push_str("\n… "); + out.push_str(&omitted_matches.to_string()); + out.push_str(" matches"); + if omitted_files > 0 { + out.push_str(" in "); + out.push_str(&omitted_files.to_string()); + out.push_str(" files"); + } + out.push_str(" omitted\n"); + } + for line in ungrouped { + out.push_str(&line); + out.push('\n'); + } + out +} + +fn split_grep_line(line: &str) -> Option<(&str, &str, &str)> { + let (file, rest) = line.split_once(':')?; + if file.is_empty() || file.starts_with(' ') { + return None; + } + let (line_no, text) = rest.split_once(':')?; + if !line_no.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some((file, line_no, text.trim_start())) +} + +fn collapse_match_text(text: &str) -> String { + let collapsed = collapse_parenthesized_segment(text, 48); + primitives::truncate_line(&collapsed, 140) +} + +fn collapse_parenthesized_segment(text: &str, min_len: usize) -> String { + let Some(open) = text.find('(') else { + return text.to_string(); + }; + let Some(close_rel) = text[open + 1..].find(')') else { + return text.to_string(); + }; + let close = open + 1 + close_rel; + if close.saturating_sub(open) < min_len { + return text.to_string(); + } + let mut out = String::new(); + out.push_str(&text[..=open]); + out.push_str("..."); + out.push_str(&text[close..]); + out +} + +fn compact_find_output(input: &str) -> String { + let paths: Vec<&str> = input + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if paths.len() <= 20 { + return input.to_string(); + } + + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut skipped_noise = 0usize; + for raw in &paths { + let normalized = normalize_listing_path(raw); + if normalized.is_empty() { + continue; + } + if path_has_noise_dir(&normalized) { + skipped_noise += 1; + continue; + } + let path = Path::new(&normalized); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .map_or_else(|| normalized.clone(), ToString::to_string); + let dir = path + .parent() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .map_or(".", |value| value); + grouped.entry(dir.to_string()).or_default().push(name); + } + + if grouped.is_empty() { + return primitives::compact_listing(input, 80); + } + + let mut out = format!("find: {} paths in {} dirs\n", paths.len(), grouped.len()); + for (dir, names) in grouped.iter().take(16) { + out.push('\n'); + out.push_str(dir); + out.push_str("/ "); + push_wrapped_names(&mut out, names, 4, 24); + } + if grouped.len() > 16 { + out.push_str("\n… "); + out.push_str(&(grouped.len() - 16).to_string()); + out.push_str(" dirs omitted\n"); + } + if skipped_noise > 0 { + out.push_str("… "); + out.push_str(&skipped_noise.to_string()); + out.push_str(" noisy paths omitted\n"); + } + out +} + +fn normalize_listing_path(raw: &str) -> String { + raw.trim() + .trim_start_matches("./") + .trim_end_matches('/') + .to_string() +} + +fn path_has_noise_dir(path: &str) -> bool { + path.split('/').any(|part| { + matches!( + part, + ".git" | "node_modules" | "target" | "dist" | "build" | ".next" | ".turbo" | ".cache" + ) + }) +} + +fn push_wrapped_names(out: &mut String, names: &[String], per_line: usize, max_names: usize) { + let shown = names.len().min(max_names); + for (idx, name) in names.iter().take(shown).enumerate() { + if idx > 0 { + if idx % per_line == 0 { + out.push_str("\n "); + } else { + out.push(' '); + } + } + out.push_str(name); + } + out.push('\n'); + if names.len() > max_names { + out.push_str(" … "); + out.push_str(&(names.len() - max_names).to_string()); + out.push_str(" more\n"); + } +} + +struct LsEntry { + name: String, + is_dir: bool, + size: Option, + is_file: bool, +} + +fn compact_ls_output(input: &str) -> Option { + let entries: Vec = input.lines().filter_map(parse_ls_long_line).collect(); + if entries.len() <= 20 { + return None; + } + + let dir_count = entries.iter().filter(|entry| entry.is_dir).count(); + let file_count = entries.iter().filter(|entry| entry.is_file).count(); + let mut ext_counts: BTreeMap = BTreeMap::new(); + for entry in entries.iter().filter(|entry| entry.is_file) { + if let Some(ext) = Path::new(&entry.name) + .extension() + .and_then(|value| value.to_str()) + { + *ext_counts.entry(ext.to_string()).or_default() += 1; + } + } + + let mut out = String::new(); + for entry in entries.iter().filter(|entry| entry.is_dir).take(12) { + out.push_str(&entry.name); + out.push_str("/\n"); + } + for entry in entries.iter().filter(|entry| entry.is_file).take(36) { + out.push_str(&entry.name); + if let Some(size) = entry.size { + out.push_str(" "); + out.push_str(&format_human_size(size)); + } + out.push('\n'); + } + let shown = dir_count.min(12) + file_count.min(36); + if entries.len() > shown { + out.push_str("… "); + out.push_str(&(entries.len() - shown).to_string()); + out.push_str(" entries omitted\n"); + } + out.push('\n'); + out.push_str(&file_count.to_string()); + out.push_str(" files, "); + out.push_str(&dir_count.to_string()); + out.push_str(" dirs"); + if !ext_counts.is_empty() { + let ext_summary = ext_counts + .iter() + .take(4) + .map(|(ext, count)| format!("{count} .{ext}")) + .collect::>() + .join(", "); + out.push_str(" ("); + out.push_str(&ext_summary); + out.push(')'); + } + out.push('\n'); + Some(out) +} + +fn parse_ls_long_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.starts_with("total ") || trimmed.is_empty() { + return None; + } + let kind = trimmed.chars().next()?; + if !matches!(kind, 'd' | '-' | 'l') { + return None; + } + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 9 { + return None; + } + let name = parts[8..].join(" "); + if matches!(name.as_str(), "." | "..") { + return None; + } + Some(LsEntry { + name, + is_dir: kind == 'd', + size: parts.get(4).and_then(|value| value.parse().ok()), + is_file: kind == '-', + }) +} + +fn format_human_size(size: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + let value = size as f64; + if value >= GIB { + format!("{:.1}G", value / GIB) + } else if value >= MIB { + format!("{:.1}M", value / MIB) + } else if value >= KIB { + format!("{:.1}K", value / KIB) + } else { + format!("{size}B") + } +} + +fn compact_cat_output(ctx: &MinimizerCtx<'_>, input: &str) -> String { + let Some(path) = extract_single_path_arg(ctx.command, ctx.program) else { + return input.to_string(); + }; + if let Some(summary) = summarize_manifest(&path, input) { + return summary; + } + if !is_source_path(&path) { + return input.to_string(); + } + compact_source_outline(input) +} + +fn extract_single_path_arg(command: &str, program: &str) -> Option { + let mut saw_program = false; + for raw in command.split_whitespace() { + let token = raw.trim_matches(|ch| ch == '\'' || ch == '"'); + let normalized = token.rsplit('/').next().unwrap_or(token); + if !saw_program { + if normalized == program { + saw_program = true; + } + continue; + } + if token.starts_with('-') { + continue; + } + return Some(token.to_string()); + } + None +} + +fn summarize_manifest(path: &str, input: &str) -> Option { + let name = Path::new(path).file_name()?.to_str()?; + match name { + "Cargo.toml" => summarize_cargo_toml(input), + "package.json" => summarize_package_json(input), + "go.mod" => summarize_go_mod(input), + _ => None, + } +} + +fn summarize_cargo_toml(input: &str) -> Option { + let mut package_name = None; + let mut dependencies = Vec::new(); + let mut section = ""; + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + section = trimmed.trim_matches(&['[', ']'][..]); + continue; + } + if section == "package" && trimmed.starts_with("name") && package_name.is_none() { + package_name = parse_toml_string_value(trimmed); + continue; + } + if matches!(section, "dependencies" | "dev-dependencies" | "build-dependencies") + && let Some(dep) = parse_toml_dependency_line(trimmed) + { + dependencies.push(dep); + } + } + if dependencies.is_empty() { + return None; + } + let mut out = String::from("Cargo.toml"); + if let Some(name) = package_name { + out.push_str(": "); + out.push_str(&name); + } + out.push('\n'); + out.push_str("dependencies: "); + out.push_str(&dependencies.len().to_string()); + out.push('\n'); + for dep in dependencies.iter().take(15) { + out.push_str(" "); + out.push_str(dep); + out.push('\n'); + } + if dependencies.len() > 15 { + out.push_str(" … "); + out.push_str(&(dependencies.len() - 15).to_string()); + out.push_str(" more\n"); + } + Some(out) +} + +fn parse_toml_string_value(line: &str) -> Option { + let (_, value) = line.split_once('=')?; + let value = value.trim(); + Some(value.trim_matches('"').to_string()) +} + +fn parse_toml_dependency_line(line: &str) -> Option { + if line.is_empty() || line.starts_with('#') { + return None; + } + let (name, value) = line.split_once('=')?; + let name = name.trim(); + if name.is_empty() { + return None; + } + let version = parse_dependency_version(value.trim()); + Some(match version { + Some(version) => format!("{name} {version}"), + None => name.to_string(), + }) +} + +fn parse_dependency_version(value: &str) -> Option { + if value.starts_with('"') { + return Some(value.trim_matches('"').to_string()); + } + if let Some(start) = value.find("version") { + let after = value[start..].split_once('=')?.1.trim(); + if let Some(rest) = after.strip_prefix('"') + && let Some(end) = rest.find('"') + { + return Some(rest[..end].to_string()); + } + return after + .split_whitespace() + .next() + .map(|version| version.trim_matches(&[',', '}'][..]).to_string()); + } + None +} + +fn summarize_package_json(input: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(input).ok()?; + let name = value.get("name").and_then(|value| value.as_str()); + let mut deps = Vec::new(); + for section in ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] { + let Some(object) = value.get(section).and_then(|value| value.as_object()) else { + continue; + }; + for (dep, version) in object { + let version = version.as_str().unwrap_or(""); + deps.push(if version.is_empty() { + dep.clone() + } else { + format!("{dep} {version}") + }); + } + } + if deps.is_empty() { + return None; + } + let mut out = String::from("package.json"); + if let Some(name) = name { + out.push_str(": "); + out.push_str(name); + } + out.push('\n'); + out.push_str("dependencies: "); + out.push_str(&deps.len().to_string()); + out.push('\n'); + for dep in deps.iter().take(15) { + out.push_str(" "); + out.push_str(dep); + out.push('\n'); + } + if deps.len() > 15 { + out.push_str(" … "); + out.push_str(&(deps.len() - 15).to_string()); + out.push_str(" more\n"); + } + Some(out) +} + +fn summarize_go_mod(input: &str) -> Option { + let mut module = None; + let mut deps = Vec::new(); + let mut in_require_block = false; + for line in input.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("module ") { + module = Some(value.trim().to_string()); + continue; + } + if trimmed == "require (" { + in_require_block = true; + continue; + } + if in_require_block && trimmed == ")" { + in_require_block = false; + continue; + } + if let Some(dep) = parse_go_require_line(trimmed, in_require_block) { + deps.push(dep); + } + } + if deps.is_empty() { + return None; + } + let mut out = String::from("go.mod"); + if let Some(module) = module { + out.push_str(": "); + out.push_str(&module); + } + out.push('\n'); + out.push_str("dependencies: "); + out.push_str(&deps.len().to_string()); + out.push('\n'); + for dep in deps.iter().take(15) { + out.push_str(" "); + out.push_str(dep); + out.push('\n'); + } + if deps.len() > 15 { + out.push_str(" … "); + out.push_str(&(deps.len() - 15).to_string()); + out.push_str(" more\n"); + } + Some(out) +} + +fn parse_go_require_line(line: &str, in_block: bool) -> Option { + let rest = if in_block { + line + } else { + line.strip_prefix("require ")? + }; + let mut parts = rest.split_whitespace(); + let name = parts.next()?; + let version = parts.next().unwrap_or(""); + if name.is_empty() || name.starts_with("//") { + return None; + } + Some(if version.is_empty() { + name.to_string() + } else { + format!("{name} {version}") + }) +} + +fn is_source_path(path: &str) -> bool { + let Some(ext) = Path::new(path).extension().and_then(|value| value.to_str()) else { + return false; + }; + matches!( + ext, + "rs" + | "ts" | "tsx" + | "js" | "jsx" + | "py" | "go" + | "java" + | "c" | "cc" + | "cpp" + | "h" | "hpp" + | "swift" + | "kt" | "rb" + ) +} + +fn compact_source_outline(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() < 160 && input.len() < 12_000 { + return input.to_string(); + } + + let mut out = String::new(); + let mut emitted = 0usize; + for line in &lines { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("//") { + continue; + } + if is_source_import_or_module(trimmed) { + out.push_str(&primitives::truncate_line(trimmed, 140)); + out.push('\n'); + emitted += 1; + if emitted >= 40 { + break; + } + } + } + + if has_content(&out) { + out.push('\n'); + } + + let mut declarations = 0usize; + for line in &lines { + if declarations >= 80 { + break; + } + if line.chars().next().is_some_and(char::is_whitespace) { + continue; + } + let trimmed = line.trim(); + if !is_source_declaration(trimmed) { + continue; + } + out.push_str(&render_source_declaration(trimmed)); + out.push('\n'); + declarations += 1; + } + + if declarations == 0 { + return primitives::head_tail_lines(input, 60, 30); + } + if lines.len() > emitted + declarations { + out.push_str("… "); + out.push_str(&lines.len().to_string()); + out.push_str(" lines summarized\n"); + } + out +} + +fn is_source_import_or_module(trimmed: &str) -> bool { + trimmed.starts_with("use ") + || trimmed.starts_with("mod ") + || trimmed.starts_with("import ") + || trimmed.starts_with("from ") + || trimmed.starts_with("package ") +} + +fn is_source_declaration(trimmed: &str) -> bool { + let without_vis = trimmed + .strip_prefix("pub ") + .or_else(|| trimmed.strip_prefix("export ")) + .or_else(|| trimmed.strip_prefix("async ")) + .unwrap_or(trimmed); + without_vis.starts_with("fn ") + || without_vis.starts_with("struct ") + || without_vis.starts_with("enum ") + || without_vis.starts_with("trait ") + || without_vis.starts_with("impl ") + || without_vis.starts_with("type ") + || without_vis.starts_with("class ") + || without_vis.starts_with("interface ") + || without_vis.starts_with("function ") + || without_vis.starts_with("def ") +} + +fn render_source_declaration(trimmed: &str) -> String { + let line = primitives::truncate_line(trimmed, 160); + if let Some(before) = line.strip_suffix('{') { + let mut out = before.trim_end().to_string(); + out.push_str(" { ... }"); + return out; + } + if let Some(index) = line.find('{') { + let mut out = line[..index].trim_end().to_string(); + out.push_str(" { ... }"); + return out; + } + line +} + fn compact_summary_output(input: &str) -> String { let lines: Vec<&str> = input.lines().collect(); if lines.len() <= 30 { @@ -58,6 +717,10 @@ fn is_summary_line(line: &str) -> bool { || lower.contains(" files ") } +fn has_content(text: &str) -> bool { + text.lines().any(|line| !line.trim().is_empty()) +} + #[cfg(test)] mod tests { use super::*; @@ -67,6 +730,14 @@ mod tests { MinimizerCtx { program, subcommand: None, command: program, config: cfg } } + fn ctx_command<'a>( + program: &'a str, + command: &'a str, + cfg: &'a MinimizerConfig, + ) -> MinimizerCtx<'a> { + MinimizerCtx { program, subcommand: None, command, config: cfg } + } + #[test] fn groups_grep_by_file() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; @@ -75,6 +746,24 @@ mod tests { assert_eq!(out.text, "a.rs:\n 1:foo\n 2:bar\n"); } + #[test] + fn compacts_large_grep_output_with_summary() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("grep", &cfg); + let mut input = String::new(); + for idx in 0..20 { + input.push_str("src/file"); + input.push_str(&idx.to_string()); + input.push_str( + ".rs:17:pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result<()> {\n", + ); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.starts_with("grep: 20 matches in 20 files")); + assert!(out.text.contains("17: pub fn run(...) -> Result<()> {")); + assert!(out.text.contains("matches in 8 files omitted")); + } + #[test] fn preserves_long_cat_output() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; @@ -84,6 +773,39 @@ mod tests { assert_eq!(out.text, input); } + #[test] + fn summarizes_cargo_manifest_from_cat() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx_command("cat", "cat Cargo.toml", &cfg); + let input = "[package]\nname = \"rtk\"\n\n[dependencies]\nclap = { version = \"4\", \ + features = [\"derive\"] }\nanyhow = \"1.0\"\nserde_json = \"1\"\n"; + let out = filter(&ctx, input, 0); + assert_eq!( + out.text, + "Cargo.toml: rtk\ndependencies: 3\n clap 4\n anyhow 1.0\n serde_json 1\n" + ); + } + + #[test] + fn outlines_large_source_cat() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx_command("cat", "cat src/main.rs", &cfg); + let mut input = String::from("use anyhow::Result;\nmod cargo_cmd;\n\nstruct Cli {\n"); + for idx in 0..180 { + input.push_str(" field_"); + input.push_str(&idx.to_string()); + input.push_str(": String,\n"); + } + input.push_str("}\nfn main() -> Result<()> {\n Ok(())\n}\n"); + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("use anyhow::Result;")); + assert!(out.text.contains("mod cargo_cmd;")); + assert!(out.text.contains("struct Cli { ... }")); + assert!(out.text.contains("fn main() -> Result<()> { ... }")); + assert!(out.text.contains("lines summarized")); + assert!(!out.text.contains("field_179")); + } + #[test] fn preserves_short_read_output() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; @@ -93,6 +815,45 @@ mod tests { assert_eq!(out.text, input); } + #[test] + fn compacts_find_paths_by_directory_and_skips_noise_dirs() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("find", &cfg); + let mut input = String::from("./target/debug/build/out/private.rs\n"); + for idx in 0..30 { + input.push_str("./src/module"); + input.push_str(&(idx / 10).to_string()); + input.push_str("/file"); + input.push_str(&idx.to_string()); + input.push_str(".rs\n"); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.starts_with("find: 31 paths in 3 dirs")); + assert!(out.text.contains("src/module0/ file0.rs file1.rs")); + assert!(out.text.contains("1 noisy paths omitted")); + assert!(!out.text.contains("target/debug")); + } + + #[test] + fn compacts_long_ls_listing() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("ls", &cfg); + let mut input = String::from("total 928\n"); + input.push_str("drwxr-xr-x 6 user staff 192 2 feb 21:35 discover\n"); + input.push_str("drwxr-xr-x 5 user staff 160 2 feb 21:35 parser\n"); + for idx in 0..25 { + input.push_str("-rw-r--r-- 1 user staff "); + input.push_str(&(1024 * (idx + 1)).to_string()); + input.push_str(" 2 feb 21:35 file"); + input.push_str(&idx.to_string()); + input.push_str(".rs\n"); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("discover/")); + assert!(out.text.contains("file0.rs 1.0K")); + assert!(out.text.contains("25 files, 2 dirs (25 .rs)")); + } + #[test] fn compacts_df_output_without_losing_filesystem_header() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; diff --git a/crates/pi-natives/src/shell/minimizer/filters/python.rs b/crates/pi-natives/src/shell/minimizer/filters/python.rs index 1075ed560..b8ab0486a 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/python.rs +++ b/crates/pi-natives/src/shell/minimizer/filters/python.rs @@ -171,6 +171,7 @@ fn is_pytest_section_delimiter(trimmed: &str) -> bool { fn is_pytest_pass_noise(trimmed: &str) -> bool { trimmed.is_empty() + || trimmed.contains("test session starts") || trimmed.starts_with("collecting ") || trimmed.starts_with("collected ") || trimmed.starts_with("rootdir:") @@ -178,11 +179,20 @@ fn is_pytest_pass_noise(trimmed: &str) -> bool { || trimmed.starts_with("plugins:") || trimmed.starts_with("platform ") || trimmed.starts_with("cachedir:") + || is_pytest_verbose_pass_line(trimmed) || trimmed .chars() .all(|ch| matches!(ch, '.' | 's' | 'S' | 'x' | 'X' | 'f' | 'F' | 'E')) } +fn is_pytest_verbose_pass_line(trimmed: &str) -> bool { + if !trimmed.contains("::") { + return false; + } + let mut parts = trimmed.split_whitespace(); + parts.any(|part| matches!(part, "PASSED" | "SKIPPED" | "XPASS" | "XFAIL")) +} + fn is_ruff_format(ctx: &MinimizerCtx<'_>) -> bool { ctx.subcommand == Some("format") || ctx.command.split_whitespace().any(|part| part == "format") } @@ -291,6 +301,17 @@ mod tests { assert!(out.contains("5 failed, 1698 passed, 2 skipped in 108.89s")); } + #[test] + fn pytest_verbose_success_collapses_to_summary() { + let input = "===== test session starts ======\nplatform darwin -- Python 3.14.3, \ + pytest-9.0.2\ncachedir: .pytest_cache\nrootdir: /app\nplugins: \ + anyio-4.12.1\ncollected 33 items\n\ntest_utils.py::TestStringUtils::test_strip \ + PASSED [ 3%]\ntest_utils.py::TestListOps::test_flatten PASSED \ + [100%]\n\n====== 33 passed in 0.05s ======\n"; + let out = filter_pytest(input, 0); + assert_eq!(out, "====== 33 passed in 0.05s ======\n"); + } + #[test] fn ruff_format_preserves_changed_files_and_summaries() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00c62d017..ebd50bae8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added full-output retrieval metadata to minimized shell command output by appending an `artifact://` footer with byte counts, allowing users to open the original unminimized command output @@ -16,6 +15,7 @@ ### Fixed +- Fixed shell execution output to replace raw streamed bash output with the minimizer’s rewritten text before final output while still preserving the full original output as artifact metadata - Fixed bash command minimization to save the full unminimized output as a `bash-original` artifact during AgentSession shell execution, enabling `artifact://` access to complete command output - Fixed minimized shell output for compound bash commands such as `cmd && next` and `cmd ; next`, preserving the original unminimized output through `artifact://` without filtering piped command output - Fixed streaming chunk previews that could display an incomplete trailing edit as a deletion when partial JSON temporarily converted in-flight values to `null` diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index badb54e88..379067516 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -234,20 +234,24 @@ export async function executeBash(command: string, options?: BashExecutorOptions }; } - // When the native minimizer rewrote the output, persist the original - // as a session artifact and splice an `artifact://` footer into the - // visible text so the agent can retrieve the raw bytes losslessly. + // When the native minimizer rewrote the output, swap the sink's accumulated + // raw stream for the minimized text, persist the original as a session + // artifact, and splice an `artifact://` footer into the visible text so + // the agent can retrieve the raw bytes losslessly. const minimized = winner.result.minimized; - if (minimized && options?.onMinimizedSave) { - const artifactId = await options.onMinimizedSave(minimized.originalText, { - filter: minimized.filter, - inputBytes: minimized.inputBytes, - outputBytes: minimized.outputBytes, - }); - if (artifactId) { - sink.push( - `\n… full output: artifact://${artifactId} (${minimized.inputBytes} → ${minimized.outputBytes} bytes)\n`, - ); + if (minimized) { + sink.replace(minimized.text); + if (options?.onMinimizedSave) { + const artifactId = await options.onMinimizedSave(minimized.originalText, { + filter: minimized.filter, + inputBytes: minimized.inputBytes, + outputBytes: minimized.outputBytes, + }); + if (artifactId) { + sink.push( + `\n… full output: artifact://${artifactId} (${minimized.inputBytes} → ${minimized.outputBytes} bytes)\n`, + ); + } } } diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index 890f1550b..668461fcb 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -680,6 +680,17 @@ export class OutputSink { }); } + /** + * Replace the in-memory buffer with the given text while preserving the + * streaming counters (totalLines/totalBytes reflect the raw chunks that + * already reached the sink). Used when an upstream minimizer rewrites the + * captured output after the raw bytes have already been streamed. + */ + replace(text: string): void { + this.#buffer = text; + this.#bufferBytes = Buffer.byteLength(text, "utf-8"); + } + async dump(notice?: string): Promise { const noticeLine = notice ? `[${notice}]\n` : ""; const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0; diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index ed3cfe340..b2cab7bef 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,22 +1,26 @@ # Changelog ## [Unreleased] - ### Added +- Added `text` to `MinimizerResult` so consumers can replace rewritten output with the minimized replacement text - Added `settingsHash` to `MinimizerOptions` to verify the minimizer `settingsPath` contents against a xxHash64 digest before applying them - Added `minimized` output telemetry via `MinimizerResult` on `ShellExecuteResult` and `ShellRunResult`, exposing the applied minimizer filter and original/minimized byte counts when output is rewritten - Added a new `minimizer` option to `ShellExecuteOptions` and `ShellOptions` to configure per-command output minimization - Added the `MinimizerOptions` API with controls for enabling minimization, overriding settings via `settingsPath`, allow/deny lists (`only`, `except`), and `maxCaptureBytes` capture limits -### Security +### Changed -- Added trust-gated loading for minimizer settings by requiring a matching `settingsHash` before accepting a settings file +- Changed the shell output minimizer to more aggressively compact successful test runs, git output, large listings, grep/find results, source reads, and dependency manifests ### Removed - Removed `PI_DEV` loader diagnostic env var and associated console logging in the native addon loader +### Security + +- Added trust-gated loading for minimizer settings by requiring a matching `settingsHash` before accepting a settings file + ## [14.2.0] - 2026-04-23 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 0cd8ded48..ebfbfd40c 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1037,11 +1037,13 @@ export interface MinimizerOptions { } /** - * Telemetry for a single minimization, surfaced when the minimizer - * actually rewrote the command's output. The session layer is expected to - * persist `original_text` via its `ArtifactManager` and splice the - * resulting `artifact://` reference into whatever is shown to the - * agent. + * Telemetry for a single minimization. + * + * Surfaced when the minimizer actually rewrote the command's output. The + * session layer is expected to persist `original_text` via its + * `ArtifactManager`, splice the resulting `artifact://` reference + * into `text`, and replace any previously streamed raw output with the + * minimized text. */ export interface MinimizerResult { /** @@ -1049,6 +1051,12 @@ export interface MinimizerResult { * `"pipeline:gradle"`, `"pipeline+builtin"`). */ filter: string + /** + * The minimized replacement text. Callers that streamed raw chunks + * during execution should clear and replace their accumulated output + * with this text. + */ + text: string /** The full original capture, before minimization. */ originalText: string /** Captured byte length before minimization. */