diff --git a/crates/pi-shell/src/minimizer/engine.rs b/crates/pi-shell/src/minimizer/engine.rs index d228d8d6c..f7033ac53 100644 --- a/crates/pi-shell/src/minimizer/engine.rs +++ b/crates/pi-shell/src/minimizer/engine.rs @@ -1097,10 +1097,15 @@ strip_lines_matching = [".*"] } let out = apply("rails db:migrate", &input, 0, &cfg); assert!(out.changed); - // The def's max_lines=40 produces ONE truncation marker. - // filter_rake's head_tail marker ("lines omitted") must NOT appear. - assert!(!out.text.contains("lines omitted"), "double truncation detected: {:#?}", out.text); - assert!(out.text.contains("lines truncated")); + // Only the def's max_lines=40 cap should fire; filter_rake's head_tail must + // NOT also condense. Both now emit `[…Nln elided…]`, so a single marker + // proves no double truncation (two would mean both stages fired). + assert_eq!( + out.text.matches("ln elided…]").count(), + 1, + "double truncation detected: {:#?}", + out.text + ); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/binary_tools.rs b/crates/pi-shell/src/minimizer/filters/binary_tools.rs index b0c1e60e1..5154ec180 100644 --- a/crates/pi-shell/src/minimizer/filters/binary_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/binary_tools.rs @@ -73,7 +73,7 @@ mod tests { // 50 head + 20 tail + 1 marker = 71 lines let line_count = out.text.lines().count(); assert_eq!(line_count, HEAD_LINES + TAIL_LINES + 1, "got {line_count} lines: {out:?}"); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); // Head anchor preserved. assert!(out.text.starts_with("00000000: 00000000")); } @@ -95,7 +95,7 @@ mod tests { let context = ctx("strings", "strings /bin/ls", &cfg); let out = filter(&context, &input, 0); assert!(out.changed); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] @@ -105,7 +105,7 @@ mod tests { let context = ctx("od", "od -c /bin/ls", &cfg); let out = filter(&context, &input, 0); assert!(out.changed); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/bun.rs b/crates/pi-shell/src/minimizer/filters/bun.rs index af86b5710..d1f1e6d59 100644 --- a/crates/pi-shell/src/minimizer/filters/bun.rs +++ b/crates/pi-shell/src/minimizer/filters/bun.rs @@ -253,9 +253,9 @@ fn compact_bun_check_output(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) } let omitted = nonzero_exits.len() + diagnostics.len(); if omitted > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(omitted - 40).to_string()); - out.push_str(" diagnostic lines omitted\n"); + out.push_str(" diagnostic lines elided…]\n"); } Some(out) } diff --git a/crates/pi-shell/src/minimizer/filters/cloud.rs b/crates/pi-shell/src/minimizer/filters/cloud.rs index 5495585b3..527f381fc 100644 --- a/crates/pi-shell/src/minimizer/filters/cloud.rs +++ b/crates/pi-shell/src/minimizer/filters/cloud.rs @@ -474,7 +474,7 @@ fn compact_aws_generic(root: &Value) -> Option { let mut out = compact_named_rows(&columns.iter().map(String::as_str).collect::>(), &values); if rows.len() > MAX_AWS_ROWS { - let _ = writeln!(out, "... +{} more {name}", rows.len() - MAX_AWS_ROWS); + let _ = writeln!(out, "[…{} {name} elided…]", rows.len() - MAX_AWS_ROWS); } return Some(out); } @@ -559,7 +559,7 @@ fn compact_named_rows(headers: &[&str], rows: &[Vec]) -> String { out.push('\n'); } if rows.len() > MAX_AWS_ROWS { - let _ = writeln!(out, "... +{} more rows", rows.len() - MAX_AWS_ROWS); + let _ = writeln!(out, "[…{} rows elided…]", rows.len() - MAX_AWS_ROWS); } out } @@ -761,9 +761,7 @@ fn compact_aws_dynamodb_items(items: &[&serde_json::Map]) -> Stri out.push('\n'); } if items.len() > 40 { - out.push_str("… "); - out.push_str(&(items.len() - 40).to_string()); - out.push_str(" item(s) omitted …\n"); + let _ = writeln!(out, "[…{} items elided…]", items.len() - 40); } let _ = writeln!(out, "{} item(s)", items.len()); out @@ -1010,7 +1008,7 @@ fn compact_line(line: &str, max_chars: usize) -> String { let edge = max_chars / 2; let start: String = chars.iter().take(edge).collect(); let end: String = chars.iter().skip(chars.len() - edge).collect(); - format!("{start} … {} chars omitted … {end}", chars.len() - edge * 2) + format!("{start}[…{}ch elided…]{end}", chars.len() - edge * 2) } fn looks_like_table(input: &str) -> bool { @@ -1073,7 +1071,7 @@ fn compact_delimited_table(input: &str, max_rows: usize) -> String { } } if data_rows > max_rows { - out.push(format!("… {} more rows", data_rows - max_rows)); + out.push(format!("[…{} rows elided…]", data_rows - max_rows)); } join_lines(out) } @@ -1114,7 +1112,7 @@ fn compact_psql_table(input: &str) -> String { } if data_rows > MAX_PSQL_ROWS { - out.push(format!("... +{} more rows", data_rows - MAX_PSQL_ROWS)); + out.push(format!("[…{} rows elided…]", data_rows - MAX_PSQL_ROWS)); } out.extend(row_count_lines); join_lines(out) @@ -1152,7 +1150,7 @@ fn compact_psql_expanded(input: &str) -> String { } flush_record(&mut out, &mut current, records); if records > MAX_PSQL_ROWS { - out.push(format!("... +{} more records", records - MAX_PSQL_ROWS)); + out.push(format!("[…{} records elided…]", records - MAX_PSQL_ROWS)); } out.extend(row_count_lines); join_lines(out) diff --git a/crates/pi-shell/src/minimizer/filters/docker.rs b/crates/pi-shell/src/minimizer/filters/docker.rs index bc27d7c70..d6e81d2bd 100644 --- a/crates/pi-shell/src/minimizer/filters/docker.rs +++ b/crates/pi-shell/src/minimizer/filters/docker.rs @@ -745,9 +745,9 @@ fn compact_table(input: &str, visible_rows: usize) -> String { out.push_str(line.trim_end()); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&(lines.len() - 1 - visible_rows).to_string()); - out.push_str(" more rows\n"); + out.push_str(" rows elided…]\n"); out } @@ -908,7 +908,7 @@ mod tests { let out = filter(&compose_ctx, &input, 0).text; assert!(out.contains("20 rows")); assert!(out.contains("svc-0")); - assert!(out.contains("… 8 more rows")); + assert!(out.contains("[…8 rows elided…]")); } #[test] @@ -988,7 +988,7 @@ mod tests { assert!(out.contains("api-1 | request 0 complete")); assert!(out.contains("api-1 | WARN cache miss")); assert!(out.contains("worker | failed to process job")); - assert!(out.contains("omitted")); + assert!(out.contains("elided")); } #[test] @@ -1000,7 +1000,7 @@ mod tests { } let out = compact_table(&input, 10); assert!(out.contains("25 rows")); - assert!(out.contains("… 15 more rows")); + assert!(out.contains("[…15 rows elided…]")); } fn ctx<'a>( @@ -1071,7 +1071,7 @@ mod tests { assert!(out.contains("25 rows"), "docker --format table output should still compact: {out}"); assert!( - out.contains("… 13 more rows"), + out.contains("[…13 rows elided…]"), "docker --format table should keep table omission: {out}" ); } @@ -1390,7 +1390,7 @@ mod tests { } let out = filter(&kubectl_ctx, &input, 0).text; assert!( - out.contains("lines omitted") || out.contains("lines truncated"), + out.contains("ln elided…]"), "kubectl apply output should be head/tail condensed: {out}" ); } diff --git a/crates/pi-shell/src/minimizer/filters/dotnet.rs b/crates/pi-shell/src/minimizer/filters/dotnet.rs index 51ef76248..0838532e3 100644 --- a/crates/pi-shell/src/minimizer/filters/dotnet.rs +++ b/crates/pi-shell/src/minimizer/filters/dotnet.rs @@ -184,9 +184,9 @@ fn compact_format_json(input: &str) -> Option { out.push('\n'); } if rows.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(rows.len() - 40).to_string()); - out.push_str(" more diagnostics\n"); + out.push_str(" diagnostics elided…]\n"); } Some(out) } diff --git a/crates/pi-shell/src/minimizer/filters/gh.rs b/crates/pi-shell/src/minimizer/filters/gh.rs index 7ca39e0b3..914f16c27 100644 --- a/crates/pi-shell/src/minimizer/filters/gh.rs +++ b/crates/pi-shell/src/minimizer/filters/gh.rs @@ -319,7 +319,7 @@ mod tests { // First failures stay verbatim with their actionable URLs. assert!(out.text.contains("X\tcheck0\t1s\thttps://ci.test/0")); // The output is capped, not 501 lines of raw rows. - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); assert!(out.text.lines().count() < 60); assert!(!out.text.contains("https://ci.test/499")); } diff --git a/crates/pi-shell/src/minimizer/filters/git.rs b/crates/pi-shell/src/minimizer/filters/git.rs index 3320433fd..251f65d8c 100644 --- a/crates/pi-shell/src/minimizer/filters/git.rs +++ b/crates/pi-shell/src/minimizer/filters/git.rs @@ -209,7 +209,7 @@ fn compact_diff_listing(input: &str, mode: DiffListingMode) -> String { out.push_str(entry); out.push('\n'); } - let _ = writeln!(out, "… {} files omitted …", entries.len() - DIFF_LISTING_LIMIT); + let _ = writeln!(out, "[…{} files elided…]", entries.len() - DIFF_LISTING_LIMIT); out } @@ -512,9 +512,7 @@ fn format_status_summary(summary: &StatusSummary) -> String { out.push('\n'); } if summary.paths.len() > 40 { - out.push_str("… "); - out.push_str(&(summary.paths.len() - 40).to_string()); - out.push_str(" paths omitted\n"); + let _ = writeln!(out, "[…{} paths elided…]", summary.paths.len() - 40); } out } @@ -531,9 +529,7 @@ fn condense_log(input: &str, head: usize, tail: usize) -> String { 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"); + let _ = writeln!(out, "[…{} commits elided…]", entries.len() - head - tail); for entry in entries.iter().skip(entries.len() - tail) { push_log_entry(&mut out, entry); } @@ -566,8 +562,8 @@ struct LogEntry { subject: String, body: Vec, /// Body lines dropped past the per-commit body cap, surfaced as an explicit - /// `[+N lines omitted]` marker instead of being silently lost. - omitted: usize, + /// `[…Nln elided…]` marker instead of being silently lost. + elided: usize, } /// Soft cap on rendered subject/body line width. Long commit subjects and body @@ -587,10 +583,8 @@ fn push_log_entry(out: &mut String, entry: &LogEntry) { out.push_str(&primitives::truncate_line(line, LOG_LINE_WIDTH)); out.push('\n'); } - if entry.omitted > 0 { - out.push_str(" [+"); - out.push_str(&entry.omitted.to_string()); - out.push_str(" lines omitted]\n"); + if entry.elided > 0 { + let _ = writeln!(out, " […{}ln elided…]", entry.elided); } } @@ -611,7 +605,7 @@ fn parse_log_entries(input: &str) -> Vec { hash: short_hash(hash), subject: subject.to_string(), body: Vec::new(), - omitted: 0, + elided: 0, }); continue; } @@ -627,12 +621,12 @@ fn parse_log_entries(input: &str) -> Vec { entry.subject = trimmed.to_string(); } else if !is_git_trailer(trimmed) { // Real (non-trailer) body lines past the 3-line cap are tallied so - // `push_log_entry` can emit an explicit `[+N lines omitted]` marker + // `push_log_entry` can emit an explicit `[…Nln elided…]` marker // rather than dropping them silently. if entry.body.len() < 3 { entry.body.push(trimmed.to_string()); } else { - entry.omitted += 1; + entry.elided += 1; } } } @@ -930,9 +924,7 @@ pub(crate) fn compact_diff_output(input: &str) -> String { 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"); + let _ = writeln!(out, "[…{} files from stat elided…]", files.len() - 20); } out.push_str(&format_file_count(files.len())); out.push_str(" changed, "); @@ -956,21 +948,15 @@ pub(crate) fn compact_diff_output(input: &str) -> String { 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"); + let _ = writeln!(out, " […{} changed lines elided…]", hunk.lines.len() - 6); } } if file.hunks.len() > 8 { - out.push_str(" … "); - out.push_str(&(file.hunks.len() - 8).to_string()); - out.push_str(" hunks omitted\n"); + let _ = writeln!(out, " […{} hunks elided…]", file.hunks.len() - 8); } } if files.len() > 12 { - out.push_str("\n… "); - out.push_str(&(files.len() - 12).to_string()); - out.push_str(" files omitted from changes\n"); + let _ = writeln!(out, "\n[…{} files from changes elided…]", files.len() - 12); } out } @@ -1311,7 +1297,7 @@ fn condense_diff_stat(input: &str) -> String { out.push('\n'); } if entries.len() > 20 { - let _ = writeln!(out, "… {} files omitted …", entries.len() - 20); + let _ = writeln!(out, "[…{} files elided…]", entries.len() - 20); } out } @@ -1528,7 +1514,7 @@ fn condense_worktree(input: &str) -> String { /// /// Listing-shaped lines (` []`, plus the `(bare)` and /// `(detached HEAD)` variants) get a leading `$HOME` abbreviated to `~` and are -/// capped with an omitted-count marker. Any non-listing output (`add`'s +/// capped with an elided-count marker. Any non-listing output (`add`'s /// "Preparing worktree…"/"HEAD is now at …" confirmations, errors) is left to /// `condense_noisy_output`/passthrough so its meaning is preserved. fn condense_worktree_with_home(input: &str, home: &str) -> String { @@ -1554,9 +1540,7 @@ fn condense_worktree_with_home(input: &str, home: &str) -> String { out.push('\n'); } if entries.len() > WORKTREE_LIMIT { - out.push_str("… "); - out.push_str(&(entries.len() - WORKTREE_LIMIT).to_string()); - out.push_str(" worktrees omitted …\n"); + let _ = writeln!(out, "[…{} worktrees elided…]", entries.len() - WORKTREE_LIMIT); } out } @@ -1920,7 +1904,7 @@ mod tests { input.push('\n'); } let out = filter(&ctx, &input, 0); - assert!(out.text.contains("… 22 commits omitted …")); + assert!(out.text.contains("[…22 commits elided…]")); assert!(out.text.contains("abcdef1 message 0")); assert!(!out.text.contains("message 47")); assert!(out.text.contains("abcdef1 message 69")); @@ -1983,7 +1967,7 @@ mod tests { } #[test] - fn log_body_over_cap_shows_omitted_marker() { + fn log_body_over_cap_shows_elided_marker() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log", &cfg); // Subject + 5 body lines: 3 kept, 2 past the cap surfaced as a marker. @@ -1995,7 +1979,7 @@ mod tests { assert!(out.text.contains("body line one")); assert!(out.text.contains("body line three")); assert!(!out.text.contains("body line four"), "{:?}", out.text); - assert!(out.text.contains("[+2 lines omitted]"), "{:?}", out.text); + assert!(out.text.contains("[…2ln elided…]"), "{:?}", out.text); } #[test] @@ -2062,7 +2046,7 @@ mod tests { assert!(out.text.contains("src/file-0.rs\n")); assert!(out.text.contains("src/file-19.rs\n")); assert!(!out.text.contains("src/file-20.rs\n")); - assert!(out.text.contains("… 6 files omitted …")); + assert!(out.text.contains("[…6 files elided…]")); } #[test] @@ -2091,7 +2075,7 @@ mod tests { assert!(out.text.contains("R100\told-0.rs\tnew-0.rs\n")); assert!(out.text.contains("M\tpath-1.rs\n")); assert!(!out.text.contains("path-20.rs\n")); - assert!(out.text.contains("… 4 files omitted …")); + assert!(out.text.contains("[…4 files elided…]")); } #[test] @@ -2139,7 +2123,7 @@ mod tests { assert!(out.text.contains("1\t0\tsrc/file-0.rs\n")); assert!(out.text.contains("20\t5\tsrc/file-19.rs\n")); assert!(!out.text.contains("src/file-20.rs\n")); - assert!(out.text.contains("… 2 files omitted …")); + assert!(out.text.contains("[…2 files elided…]")); } #[test] @@ -2695,13 +2679,13 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details. assert!(out.text.contains("fatal:"), "error header must survive: {:?}", out.text); assert!(out.text.contains("badref"), "offending ref must survive: {:?}", out.text); - assert!(!out.text.contains("commits omitted"), "must not fabricate commit listing on error"); + assert!(!out.text.contains("commits elided"), "must not fabricate commit listing on error"); } #[test] fn log_oneline_short_run_emits_all_entries() { // A short log that fits within head+tail should emit all entries - // without any "omitted" line, and each entry should carry the + // without any "elided" line, and each entry should carry the // 7-char short hash followed by the subject. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log -5", &cfg); @@ -2712,7 +2696,7 @@ commit abcdef1234567890\nAuthor: A \nDate: today\n feat: first\ncomm let out = filter(&ctx, input, 0); - assert!(!out.text.contains("commits omitted")); + assert!(!out.text.contains("commits elided")); assert!(out.text.contains("abcdef1 feat: first"), "{:?}", out.text); assert!(out.text.contains("1111111 fix: second"), "{:?}", out.text); assert!(out.text.contains("2222222 chore: third"), "{:?}", out.text); @@ -2780,8 +2764,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma assert!(out.contains("(bare)")); assert!(out.contains("(detached HEAD)")); assert!(!out.contains("/home/alice"), "no absolute $HOME paths should survive: {out:?}"); - // 22 entries → capped at 20 with a 2-omitted marker. - assert!(out.contains("… 2 worktrees omitted …"), "{out:?}"); + // 22 entries → capped at 20 with a 2-elided marker. + assert!(out.contains("[…2 worktrees elided…]"), "{out:?}"); assert_eq!(out.lines().filter(|l| l.starts_with('~')).count(), 20); } @@ -2799,7 +2783,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma #[test] fn worktree_porcelain_passthrough() { // `git worktree list --porcelain` emits machine-readable records; - // the minimizer must not insert "… N lines omitted …" markers. + // the minimizer must not insert "[…Nln elided…]" markers. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("worktree"), "git worktree list --porcelain", &cfg); let input = "worktree /home/user/project\nHEAD abc1234def\nbranch \ @@ -2825,7 +2809,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma fn worktree_non_porcelain_still_condensed() { // Normal (non-porcelain) listing should still go through the condenser. // Build 22 listing-shaped entries so the cap (20) is exceeded; the condenser - // would insert an "… N worktrees omitted …" marker that a passthrough would + // would insert an "[…N worktrees elided…]" marker that a passthrough would // never emit. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("worktree"), "git worktree list", &cfg); @@ -2835,8 +2819,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma } let out = filter(&ctx, &input, 0); assert!( - out.text.contains("worktrees omitted"), - "non-porcelain output over the cap must show omitted marker; got: {:?}", + out.text.contains("worktrees elided"), + "non-porcelain output over the cap must show elided marker; got: {:?}", out.text ); } diff --git a/crates/pi-shell/src/minimizer/filters/glab.rs b/crates/pi-shell/src/minimizer/filters/glab.rs index 7ca413aaa..6b8bcb730 100644 --- a/crates/pi-shell/src/minimizer/filters/glab.rs +++ b/crates/pi-shell/src/minimizer/filters/glab.rs @@ -211,11 +211,11 @@ fn filter_release_list(input: &str) -> Option { // Append omission marker when there are more releases than shown. let omitted = total.map_or(0, |t| t.saturating_sub(count)); if omitted > 0 { - let _ = writeln!(filtered, "… {omitted} releases omitted …"); + let _ = writeln!(filtered, "[…{omitted} releases elided…]"); } else if has_more { // Total not parsed from preamble but a 21st row was observed; signal // truncation. - filtered.push_str("… releases omitted (showing first 20) …\n"); + filtered.push_str("[…releases elided…]\n"); } Some(filtered) @@ -661,8 +661,8 @@ section_end:1711234600:build_script[0K let out = filter(&ctx, &input, 0); assert!(out.changed); - // Output must signal that releases were omitted. - assert!(out.text.contains("omitted"), "expected omission marker, got: {}", out.text); + // Output must signal that releases were elided. + assert!(out.text.contains("elided"), "expected omission marker, got: {}", out.text); // First release present, 21st not shown verbatim in the list. assert!(out.text.contains("Release 1")); assert!(!out.text.contains("Release 21 [")); diff --git a/crates/pi-shell/src/minimizer/filters/go.rs b/crates/pi-shell/src/minimizer/filters/go.rs index e1e893cec..05b32bf5b 100644 --- a/crates/pi-shell/src/minimizer/filters/go.rs +++ b/crates/pi-shell/src/minimizer/filters/go.rs @@ -341,9 +341,9 @@ fn summarize_golangci_json(line: &str) -> Option { out.push_str(")\n"); } if issues.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(issues.len() - 40).to_string()); - out.push_str(" more issues\n"); + out.push_str(" issues elided…]\n"); } Some(out) } @@ -506,6 +506,22 @@ mod tests { let out = filter_golangci_lint(input); assert!(out.contains("golangci-lint: 1 issues")); assert!(out.contains("main.go:7:2: unreachable code (govet)")); + + // Match up-to-40 limits, testing elison formatting + let mut many_issues = r#"{"Issues":["#.to_string(); + for i in 0..42 { + if i > 0 { + many_issues.push(','); + } + let _ = write!( + many_issues, + r#"{{"FromLinter":"govet","Text":"err {i}","Pos":{{"Filename":"main.go","Line":{i},"Column":2}}}}"# + ); + } + many_issues.push_str("]}"); + let out_many = filter_golangci_lint(&many_issues); + assert!(out_many.contains("golangci-lint: 42 issues")); + assert!(out_many.contains("[…2 issues elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/gt.rs b/crates/pi-shell/src/minimizer/filters/gt.rs index e5d6c5baf..3c76e995c 100644 --- a/crates/pi-shell/src/minimizer/filters/gt.rs +++ b/crates/pi-shell/src/minimizer/filters/gt.rs @@ -91,9 +91,7 @@ fn compact_log(input: &str) -> String { } if omitted_entries > 0 { - out.push_str("… "); - out.push_str(&omitted_entries.to_string()); - out.push_str(" entries omitted …\n"); + let _ = writeln!(out, "[…{omitted_entries} entries elided…]"); } primitives::head_tail_lines(&out, 80, 24) @@ -172,13 +170,13 @@ fn dense_sync_summary(input: &str, exit_code: i32) -> Option { // head_tail_lines bound that compact_noisy_command (the fallback) applies. // Without a cap every name lands on one unbounded line, defeating the // minimizer's bounding guarantee. Show the first DELETED_NAME_CAP names and - // summarize the rest as `+N more` (the count above stays exact). + // summarize the rest as `[…N names elided…]` (the count above stays exact). const DELETED_NAME_CAP: usize = 20; let shown = deleted_names.len().min(DELETED_NAME_CAP); let names = deleted_names[..shown].join(", "); let _ = write!(summary, " ({names}"); if deleted_names.len() > DELETED_NAME_CAP { - let _ = write!(summary, ", +{} more", deleted_names.len() - DELETED_NAME_CAP); + let _ = write!(summary, " […{} names elided…]", deleted_names.len() - DELETED_NAME_CAP); } summary.push(')'); } @@ -344,7 +342,7 @@ mod tests { assert!(out.changed); assert!(out.text.contains("abc1230")); - assert!(out.text.contains("entries omitted")); + assert!(out.text.contains("entries elided")); assert!(!out.text.contains("user@example.com")); } @@ -439,7 +437,8 @@ mod tests { fn sync_dense_summary_caps_deleted_name_list() { // A long-lived stack cleanup can delete hundreds of merged branches at // once. The dense summary must stay bounded: cap the inline name list and - // summarize the remainder as `+N more`, never emit one unbounded line. + // summarize the remainder as `[…N names elided…]`, never emit one unbounded + // line. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("sync"), &cfg); let mut input = String::from("Synced with remote\n"); @@ -452,9 +451,9 @@ mod tests { assert!(out.changed); // Exact deleted count is preserved. assert!(out.text.contains("ok sync: 1 synced, 500 deleted")); - // First names stay visible; the rest collapse to a `+N more` marker. + // First names stay visible; the rest collapse to a `[…N names elided…]` marker. assert!(out.text.contains("feat/merged-0")); - assert!(out.text.contains("+480 more")); + assert!(out.text.contains("[…480 names elided…]")); // Bounded: a single short line, not 500 names concatenated. assert!(!out.text.contains("feat/merged-499")); let longest = out.text.lines().map(str::len).max().unwrap_or(0); diff --git a/crates/pi-shell/src/minimizer/filters/js_tools.rs b/crates/pi-shell/src/minimizer/filters/js_tools.rs index 01828f959..463b5d084 100644 --- a/crates/pi-shell/src/minimizer/filters/js_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/js_tools.rs @@ -219,9 +219,9 @@ fn filter_prettier(input: &str, exit_code: i32) -> String { push_line(&mut out, error); } if errors.len() > 20 { - out.push_str("... +"); + out.push_str("[…"); out.push_str(&(errors.len() - 20).to_string()); - out.push_str(" more errors\n"); + out.push_str(" errors elided…]\n"); } return out; } @@ -391,9 +391,9 @@ fn push_file_list(out: &mut String, files: &[String], limit: usize) { push_line(out, file); } if files.len() > limit { - out.push_str("... +"); + out.push_str("[…"); out.push_str(&(files.len() - limit).to_string()); - out.push_str(" more files\n"); + out.push_str(" files elided…]\n"); } } @@ -406,6 +406,7 @@ fn push_line(out: &mut String, line: &str) { mod tests { use super::*; use crate::minimizer::MinimizerConfig; + use std::fmt::Write as _; fn ctx<'a>( program: &'a str, @@ -532,4 +533,29 @@ mod tests { assert!(out.contains("P3009")); assert!(!out.contains("Datasource")); } + + #[test] + fn prettier_errors_truncation_is_canonical() { + let mut input = String::new(); + for i in 1..=25 { + let _ = writeln!(input, "error in file{i}.js: line {i}"); + } + let out = filter_prettier(&input, 1); + assert!(out.contains("[…5 errors elided…]\n")); + assert!(!out.contains("more errors")); + assert!(!out.contains("... +")); + } + + #[test] + fn file_list_truncation_is_canonical() { + let mut files = Vec::new(); + for i in 1..=35 { + files.push(format!("src/file{i}.tsx")); + } + let mut out = String::new(); + push_file_list(&mut out, &files, 30); + assert!(out.contains("[…5 files elided…]\n")); + assert!(!out.contains("more files")); + assert!(!out.contains("... +")); + } } diff --git a/crates/pi-shell/src/minimizer/filters/jvm.rs b/crates/pi-shell/src/minimizer/filters/jvm.rs index 954c8b443..5c0c42d7f 100644 --- a/crates/pi-shell/src/minimizer/filters/jvm.rs +++ b/crates/pi-shell/src/minimizer/filters/jvm.rs @@ -655,7 +655,7 @@ impl FailuresSummaryCap { return; } if self.dropped > 0 { - let _ = write!(out, "\n… +{} more failures\n", self.dropped); + let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped); } self.in_summary = false; self.emitted = 0; @@ -665,7 +665,7 @@ impl FailuresSummaryCap { /// End-of-stream tail emission for cases where the AGG line never arrives. fn finish(&self, out: &mut String) { if self.in_summary && self.dropped > 0 { - let _ = write!(out, "\n… +{} more failures\n", self.dropped); + let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped); } } } @@ -744,7 +744,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { block.finish(&mut out); summary.finish(&mut out); if dropped_failing > 0 { - let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n"); + let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n"); } out } @@ -903,7 +903,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { block.finish(&mut out); summary.finish(&mut out); if dropped_failing > 0 { - let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n"); + let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n"); } out } @@ -1602,7 +1602,7 @@ fn filter_gradle_dependencies(input: &str) -> String { let _ = writeln!(result, " {dep}"); } if deps.len() > cap { - let _ = writeln!(result, " ... +{} more", deps.len() - cap); + let _ = writeln!(result, " […{} dependencies elided…]", deps.len() - cap); } } @@ -1994,7 +1994,7 @@ mod tests { "capped class second per-test block dropped (re-arm inherits drop); got:\n{o}" ); assert!( - o.contains("… +1 more failing test classes"), + o.contains("[…1 failing test classes elided…]"), "tail counts one class, not one per failure; got:\n{o}" ); } @@ -2214,7 +2214,7 @@ mod tests { "Fail{n} exception dropped; got:\n{o}" ); } - assert!(o.contains("… +2 more failing test classes"), "tail emitted; got:\n{o}"); + assert!(o.contains("[…2 failing test classes elided…]"), "tail emitted; got:\n{o}"); } #[test] @@ -2235,7 +2235,7 @@ mod tests { "Fail{n} dropped under cap=0; got:\n{o}" ); } - assert!(o.contains("+5 more failing test classes"), "tail counts all 5; got:\n{o}"); + assert!(o.contains("[…5 failing test classes elided…]"), "tail counts all 5; got:\n{o}"); } #[test] @@ -2256,7 +2256,7 @@ mod tests { for n in 4..=5 { assert!(!o.contains(&format!("ClassA.test{n}:25")), "entry {n} dropped; got:\n{o}"); } - let tail_idx = o.find("… +2 more failures").expect("tail must appear"); + let tail_idx = o.find("[…2 failures elided…]").expect("tail must appear"); let agg_idx = o .find("[ERROR] Tests run: 100") .expect("aggregate must appear"); diff --git a/crates/pi-shell/src/minimizer/filters/lint.rs b/crates/pi-shell/src/minimizer/filters/lint.rs index f89ac0f54..38d804534 100644 --- a/crates/pi-shell/src/minimizer/filters/lint.rs +++ b/crates/pi-shell/src/minimizer/filters/lint.rs @@ -540,9 +540,9 @@ pub fn group_diagnostics(input: &str) -> String { out.push('\n'); } if entries.len() > 12 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(entries.len() - 12).to_string()); - out.push_str(" more\n"); + out.push_str(" diagnostics elided…]\n"); } } @@ -551,9 +551,9 @@ pub fn group_diagnostics(input: &str) -> String { out.push('\n'); } if ungrouped.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(ungrouped.len() - 40).to_string()); - out.push_str(" ungrouped lines omitted\n"); + out.push_str(" ungrouped lines elided…]\n"); } out } @@ -701,7 +701,7 @@ mod tests { } let out = group_diagnostics(&input); assert!(out.contains("src/main.rs (20 diagnostics)")); - assert!(out.contains("… 8 more")); + assert!(out.contains(" […8 diagnostics elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/listing.rs b/crates/pi-shell/src/minimizer/filters/listing.rs index a130a7dd6..f3f50fbda 100644 --- a/crates/pi-shell/src/minimizer/filters/listing.rs +++ b/crates/pi-shell/src/minimizer/filters/listing.rs @@ -173,16 +173,16 @@ fn compact_grep_grouped( out.push('\n'); } if matches.len() > 4 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(matches.len() - 4).to_string()); - out.push_str(" more in file\n"); + out.push_str(" matches in file elided…]\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("\n[…"); out.push_str(&omitted_matches.to_string()); out.push_str(" matches"); if omitted_files > 0 { @@ -190,7 +190,7 @@ fn compact_grep_grouped( out.push_str(&omitted_files.to_string()); out.push_str(" files"); } - out.push_str(" omitted\n"); + out.push_str(" elided…]\n"); } for line in ungrouped { out.push_str(line); @@ -365,14 +365,14 @@ fn compact_find_output_inner(input: &str, paths: &[&str]) -> String { push_wrapped_names(&mut out, names, 4, 24); } if grouped.len() > 16 { - out.push_str("\n… "); + out.push_str("\n[…"); out.push_str(&(grouped.len() - 16).to_string()); - out.push_str(" dirs omitted\n"); + out.push_str(" dirs elided…]\n"); } if skipped_noise > 0 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&skipped_noise.to_string()); - out.push_str(" noisy paths omitted\n"); + out.push_str(" noisy paths elided…]\n"); } out } @@ -407,9 +407,9 @@ fn push_wrapped_names(out: &mut String, names: &[String], per_line: usize, max_n } out.push('\n'); if names.len() > max_names { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(names.len() - max_names).to_string()); - out.push_str(" more\n"); + out.push_str(" names elided…]\n"); } } @@ -453,9 +453,9 @@ fn compact_ls_output(input: &str) -> Option { } let shown = dir_count.min(12) + file_count.min(36); if entries.len() > shown { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(entries.len() - shown).to_string()); - out.push_str(" entries omitted\n"); + out.push_str(" entries elided…]\n"); } out.push('\n'); out.push_str(&file_count.to_string()); @@ -622,9 +622,9 @@ fn summarize_cargo_toml(input: &str) -> Option { out.push('\n'); } if dependencies.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(dependencies.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -705,9 +705,9 @@ fn summarize_package_json(input: &str) -> Option { out.push('\n'); } if deps.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(deps.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -752,9 +752,9 @@ fn summarize_go_mod(input: &str) -> Option { out.push('\n'); } if deps.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(deps.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -1265,7 +1265,7 @@ mod tests { 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")); + assert!(out.text.contains("matches in 8 files elided…]")); } #[test] @@ -1447,7 +1447,7 @@ mod tests { 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("[…1 noisy paths elided…]")); assert!(!out.text.contains("target/debug")); } @@ -1488,7 +1488,7 @@ mod tests { out.text .contains("Filesystem 1K-blocks Used Available Use% Mounted on") ); - assert!(out.text.contains("… 13 lines omitted …")); + assert!(out.text.contains("[…13ln elided…]")); assert!(out.text.contains("/dev/disk35")); } diff --git a/crates/pi-shell/src/minimizer/filters/pkg.rs b/crates/pi-shell/src/minimizer/filters/pkg.rs index 386081175..44f1e0440 100644 --- a/crates/pi-shell/src/minimizer/filters/pkg.rs +++ b/crates/pi-shell/src/minimizer/filters/pkg.rs @@ -251,7 +251,7 @@ fn compact_package_tree_output(input: &str) -> String { out.push_str(line); out.push('\n'); } - let _ = writeln!(out, "… {} package entries omitted …", lines.len() - PACKAGE_TREE_HEAD_LINES); + let _ = writeln!(out, "[…{} package entries elided…]", lines.len() - PACKAGE_TREE_HEAD_LINES); out } @@ -292,7 +292,7 @@ fn summarize_package_rows(rows: Vec) -> Option { out.push('\n'); } if rows.len() > PACKAGE_TREE_HEAD_LINES { - let _ = writeln!(out, "… {} package entries omitted …", rows.len() - PACKAGE_TREE_HEAD_LINES); + let _ = writeln!(out, "[…{} package entries elided…]", rows.len() - PACKAGE_TREE_HEAD_LINES); } Some(out) } @@ -793,7 +793,7 @@ mod tests { assert!(out.text.contains("├── dep000@1.0.0")); assert!(out.text.contains("├── dep078@1.0.0")); assert!(!out.text.contains("├── dep089@1.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -808,7 +808,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("dep000")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -825,7 +825,7 @@ mod tests { assert!(out.text.starts_with("package tree/list: 92 entries\n")); assert!(out.text.contains("react 19.0.0")); assert!(out.text.contains("└─ dependent000")); - assert!(out.text.contains("… 12 package entries omitted …")); + assert!(out.text.contains("[…12 package entries elided…]")); } #[test] @@ -889,7 +889,7 @@ mod tests { assert!(out.text.contains("pkg000 1.0.0")); assert!(out.text.contains("pkg078 1.0.78")); assert!(!out.text.contains("pkg089 1.0.89")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -905,7 +905,7 @@ mod tests { assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("project v1.0.0")); assert!(out.text.contains("pkg000")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -920,7 +920,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("requests 2.32.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -951,7 +951,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("pkg000==1.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -966,7 +966,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("dep000==2.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -996,7 +996,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 92 entries\n")); assert!(out.text.contains("pkg000")); - assert!(out.text.contains("… 12 package entries omitted …")); + assert!(out.text.contains("[…12 package entries elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/python.rs b/crates/pi-shell/src/minimizer/filters/python.rs index 1635c9242..9ff4a3351 100644 --- a/crates/pi-shell/src/minimizer/filters/python.rs +++ b/crates/pi-shell/src/minimizer/filters/python.rs @@ -172,15 +172,15 @@ fn filter_pytest(input: &str, exit_code: i32) -> String { // overflow markers so the compact output reads top-to-bottom. let error_overflow = error_blocks.saturating_sub(MAX_PYTEST_FAILURES); if error_overflow > 0 { - out.push_str("… +"); + out.push_str("[…"); out.push_str(&error_overflow.to_string()); - out.push_str(" more errors\n"); + out.push_str(" errors elided…]\n"); } let overflow = failure_blocks.saturating_sub(MAX_PYTEST_FAILURES); if overflow > 0 { - out.push_str("… +"); + out.push_str("[…"); out.push_str(&overflow.to_string()); - out.push_str(" more failures\n"); + out.push_str(" failures elided…]\n"); } if has_content(&out) { @@ -575,7 +575,7 @@ mod tests { assert!(out.contains("test_case_0"), "got: {out}"); assert!(out.contains("test_case_9"), "got: {out}"); // The overflow marker accounts for the remaining 2 verbose blocks. - assert!(out.contains("… +2 more failures"), "got: {out}"); + assert!(out.contains("[…2 failures elided…]"), "got: {out}"); // The traceback bodies for the capped blocks are gone, but the compact // short-summary one-liners for every test survive uncapped. let assert_false_lines = out.matches("E assert False").count(); @@ -664,10 +664,10 @@ mod tests { let assert_lines = out.matches("E assert 0 == 100").count(); assert_eq!(assert_lines, 5, "all 5 assertion bodies must survive: {out}"); // 5 < cap, so there is NO failure overflow stealing real tracebacks. - assert!(!out.contains("more failures"), "no failures should be capped: {out}"); + assert!(!out.contains("failures elided"), "no failures should be capped: {out}"); // Collection errors are still surfaced (under their own cap of 10). assert!(out.contains("ERROR collecting tests/test_imp_0.py"), "got: {out}"); - assert!(!out.contains("more errors"), "9 errors < cap, no error overflow: {out}"); + assert!(!out.contains("errors elided"), "9 errors < cap, no error overflow: {out}"); assert!(out.contains("pytest: 5 failed, 9 errors"), "got: {out}"); } @@ -706,9 +706,9 @@ mod tests { let out = filter_pytest(&input, 1); // Error overflow marker fires for the 2 capped collection banners… - assert!(out.contains("… +2 more errors"), "got: {out}"); + assert!(out.contains("[…2 errors elided…]"), "got: {out}"); // …but NOT a failure overflow — both real tracebacks render in full. - assert!(!out.contains("more failures"), "failures must be uncapped here: {out}"); + assert!(!out.contains("failures elided"), "failures must be uncapped here: {out}"); assert!(out.contains("test_real_0"), "got: {out}"); assert!(out.contains("test_real_1"), "got: {out}"); let assert_false = out.matches("E assert False").count(); diff --git a/crates/pi-shell/src/minimizer/filters/ruby.rs b/crates/pi-shell/src/minimizer/filters/ruby.rs index 9e5fe8bdd..6f2026e8f 100644 --- a/crates/pi-shell/src/minimizer/filters/ruby.rs +++ b/crates/pi-shell/src/minimizer/filters/ruby.rs @@ -75,7 +75,7 @@ fn filter_rspec(input: &str, exit_code: i32) -> String { let mut out = String::new(); // Each rendered failure block is buffered separately so the total can be - // capped at MAX_RENDERED_FAILURES with a `+N more failures` marker. The + // capped at MAX_RENDERED_FAILURES with a `[…N failures elided…]` marker. The // buffered blocks are rendered into `out` the moment the `Failures:` section // ends — at the summary line or the `Failed examples:` boundary — so real // rspec ordering (`Failures:` details, then summary, then `Failed examples:`) @@ -139,8 +139,8 @@ fn filter_rspec(input: &str, exit_code: i32) -> String { } /// Render the buffered `Failures:` detail blocks into `out`, capped at -/// `MAX_RENDERED_FAILURES` with a `+N more failures` marker. Runs at most once -/// — guarded by `rendered` — so the section is emitted exactly where the +/// `MAX_RENDERED_FAILURES` with a `[…N failures elided…]` marker. Runs at most +/// once — guarded by `rendered` — so the section is emitted exactly where the /// `Failures:` block ends (the summary line or `Failed examples:` boundary), /// preserving real rspec section ordering. fn render_rspec_blocks(out: &mut String, blocks: &mut Vec, rendered: &mut bool) { @@ -157,14 +157,14 @@ fn render_rspec_blocks(out: &mut String, blocks: &mut Vec, rendered: &mu out.push_str(block); } if total > MAX_RENDERED_FAILURES { - push_line(out, &format!("+{} more failures", total - MAX_RENDERED_FAILURES)); + push_line(out, &format!("[…{} failures elided…]", total - MAX_RENDERED_FAILURES)); } blocks.clear(); } /// Cap on rendered failure blocks in non-JSON rspec output. rtk uses the same /// limit (`MAX_RSPEC_FAILURES = 5`) — failure blocks carry full context, so a -/// handful is enough before collapsing to a `+N more failures` marker. +/// handful is enough before collapsing to a `[…N failures elided…]` marker. const MAX_RENDERED_FAILURES: usize = 5; fn flush_rspec_block(blocks: &mut Vec, current: &mut String) { @@ -896,7 +896,7 @@ mod tests { assert!(out.contains("Example number 1 fails")); assert!(out.contains("Example number 5 fails")); assert!(!out.contains("Example number 6 fails")); - assert!(out.contains("+2 more failures")); + assert!(out.contains("[…2 failures elided…]")); assert!(out.contains("7 examples, 7 failures")); } diff --git a/crates/pi-shell/src/minimizer/filters/rust_tools.rs b/crates/pi-shell/src/minimizer/filters/rust_tools.rs index 498fb5184..906ec13fd 100644 --- a/crates/pi-shell/src/minimizer/filters/rust_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/rust_tools.rs @@ -79,7 +79,7 @@ fn condense_rustfmt(input: &str, exit_code: i32) -> String { out.push('\n'); } if total > 3 { - let _ = writeln!(out, " … {} more", total - 3); + let _ = writeln!(out, " […{} files elided…]", total - 3); } out } @@ -125,7 +125,7 @@ mod tests { assert!(out.changed); assert!(out.text.contains("50 files reformatted")); assert!(out.text.contains("src/file_0.rs")); - assert!(out.text.contains("… 47 more")); + assert!(out.text.contains("[…47 files elided…]")); // Diff bodies must be elided. assert!(!out.text.contains("old line")); // Savings ratio ≥ 0.7 @@ -154,7 +154,7 @@ mod tests { let out = filter(&context, &input, 1); assert!(out.changed); // head_tail_lines(input, 80, 40) keeps 120 lines + marker. - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/system.rs b/crates/pi-shell/src/minimizer/filters/system.rs index c2ffb1c3f..b923b17ba 100644 --- a/crates/pi-shell/src/minimizer/filters/system.rs +++ b/crates/pi-shell/src/minimizer/filters/system.rs @@ -1,6 +1,7 @@ //! Conservative text filters for system-style commands. use std::collections::HashMap; +use std::fmt::Write as _; use super::git; use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; @@ -358,9 +359,7 @@ fn render_counted_lines(lines: &[LogLine], head: usize, tail: usize) -> String { for line in lines.iter().take(head) { push_counted_line(&mut out, &line.original, line.count); } - out.push_str("… "); - out.push_str(&(lines.len() - head - tail).to_string()); - out.push_str(" unique lines omitted …\n"); + let _ = writeln!(out, "[…{} unique lines elided…]", lines.len() - head - tail); for line in lines.iter().skip(lines.len() - tail) { push_counted_line(&mut out, &line.original, line.count); } @@ -750,9 +749,7 @@ fn compact_format_output(input: &str) -> String { out.push('\n'); } if files.len() > 50 { - out.push_str("… "); - out.push_str(&(files.len() - 50).to_string()); - out.push_str(" more files\n"); + let _ = writeln!(out, "[…{} files elided…]", files.len() - 50); } } if errors.is_empty() && summary.is_empty() && files.is_empty() { @@ -1073,7 +1070,7 @@ Time 0.42s assert!(out.text.contains("errors:")); assert!(out.text.contains("failed to parse src/bad.py")); assert!(out.text.contains("files:")); - assert!(out.text.contains("more files")); + assert!(out.text.contains("[…50 files elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/primitives.rs b/crates/pi-shell/src/minimizer/primitives.rs index 6e494b4ed..4dfa2f074 100644 --- a/crates/pi-shell/src/minimizer/primitives.rs +++ b/crates/pi-shell/src/minimizer/primitives.rs @@ -93,9 +93,9 @@ pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); for line in lines.iter().skip(lines.len() - tail) { out.push_str(line); out.push('\n'); @@ -292,9 +292,9 @@ pub fn head_lines_only(input: &str, head: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); out } @@ -307,9 +307,9 @@ pub fn tail_lines_only(input: &str, tail: usize) -> String { } let omitted = lines.len() - tail; let mut out = String::new(); - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); for line in lines.iter().skip(omitted) { out.push_str(line); out.push('\n'); @@ -330,9 +330,9 @@ pub fn max_lines(input: &str, max: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&dropped.to_string()); - out.push_str(" lines truncated …\n"); + out.push_str("ln elided…]\n"); out } @@ -380,7 +380,7 @@ mod tests { #[test] fn head_tail_marks_omitted_lines() { let out = head_tail_lines("1\n2\n3\n4\n5\n", 2, 1); - assert_eq!(out, "1\n2\n… 2 lines omitted …\n5\n"); + assert_eq!(out, "1\n2\n[…2ln elided…]\n5\n"); } #[test] @@ -397,7 +397,7 @@ mod tests { .collect::>() .join("\n"); let out = head_tail_cap(&input, CapClass::List); - assert!(out.contains("lines omitted")); + assert!(out.contains("ln elided…]")); assert!(out.lines().count() <= CapClass::List.lines() + 1); } diff --git a/docs/compaction.md b/docs/compaction.md index 84a0cb0c5..f3a172ba8 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -263,7 +263,7 @@ Cumulative behavior: - In split turns, includes turn-prefix file ops too. - `details.readFiles` excludes files also modified; `details.modifiedFiles` carries the rest (persisted shape is unchanged). -Summary text gets one `` tag appended via prompt template: a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `… (N more files omitted)` line. +Summary text gets one `` tag appended via prompt template: a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `[…N files elided…]` line. ```xml diff --git a/docs/tools/read.md b/docs/tools/read.md index 045411856..4199bf7b0 100644 --- a/docs/tools/read.md +++ b/docs/tools/read.md @@ -97,7 +97,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts ### Local text files - No selector: if summarization is enabled and the file is small enough, `#trySummarize()` calls `summarizeCode()`. - Guards: file size `<= 2 MiB` (`MAX_SUMMARY_BYTES`), line count `<= 20_000` (`MAX_SUMMARY_LINES`). - - Summary output keeps selected declarations and replaces elided spans with `...` or merged brace-pair lines containing `..`. When at least one span is elided, the text content ends with a footer like `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]` using concrete ranges from the actual elisions. + - Summary output keeps selected declarations and replaces elided spans with `…` or merged brace-pair lines containing `{ … }`. When at least one span is elided, the text content ends with a footer like `[…NNln elided; re-read needed ranges, e.g. :5-16,40-80]` using concrete ranges from the actual elisions. - When an elided block sits between matching brace lines, `#renderSummary()` may merge them into one anchored line rather than emitting separate opener/closer lines. - Explicit selector or summarization miss: streamed text read. - Default open-ended limit is `min(session setting read.defaultLimit, DEFAULT_MAX_LINES)`. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 136a38b7b..6afbfbed8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed + +- Updated the display format for truncated file operation summaries ## [16.0.8] - 2026-06-18 @@ -869,4 +872,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Changed - `Agent` constructor now has all options optional (empty options use defaults). -- `queueMessage()` is now synchronous (no longer returns a Promise). +- `queueMessage()` is now synchronous (no longer returns a Promise). \ No newline at end of file diff --git a/packages/agent/src/compaction/utils.ts b/packages/agent/src/compaction/utils.ts index e897c7bca..ddd743344 100644 --- a/packages/agent/src/compaction/utils.ts +++ b/packages/agent/src/compaction/utils.ts @@ -173,7 +173,7 @@ export function formatFileOperations( const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { - files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`; + files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } return prompt.render(fileOperationsTemplate, { files }); } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00f125294..191593926 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `__advisor.jsonl` transcript persistence for advisor model usage attribution and visibility in the Agent Hub @@ -12,6 +13,8 @@ ### Changed +- Standardized file elision markers to a compact `[…Nln elided…]` format +- Updated transcript viewer to clear cached content if the session file is deleted while opened - Refactored agent hub to use a fullscreen overlay for agent transcripts instead of inline chat - Improved transcript rendering stability by using a non-incremental builder for viewer replays - Advisor transcripts are now excluded from agent-facing surfaces like `irc`, `history://`, and peer rosters @@ -21,6 +24,7 @@ ### Fixed +- Fixed memory-leaking stale transcripts in the agent viewer when underlying files are deleted - Fixed the Agent Hub transcript viewer rendering the transcript body one column right of the "Agent Hub" title (and the title appearing to shift when scrolled to the top): the fullscreen viewer added its own outer gutter on top of the transcript rows, which already carry a 1-column left pad, so the header and body no longer shared a gutter. The viewer now renders the scroll body at full width without the extra gutter, and the file-mention row carries the same 1-column pad as every other row. - Fixed the bash tool failing with `pi-natives:command: syntax error at end of input` on a valid `&&`/`;` chain whose later pipeline stage is a compound command, e.g. `echo x && git log | while read h; do …; done | head`. The minimizer's segmented-chain runner rebuilds each chain segment from the brush AST via `pipeline.to_string()` and re-executes that string, but `simple_segment` only validated the *first* pipeline stage — so a compound later stage (`while`/`for`/`if`/subshell) was re-serialized without its terminator and re-run as broken shell. Every stage is now required to be a Display-safe simple command, and — as a general guard against the recurring class of brush `Display` round-trip divergences (previously: quoted here-doc close tags, multi-byte char/byte offsets) — each reconstructed segment is now re-parsed and must match the original pipeline shape before the chain runner executes it; any divergence runs the command whole, unsegmented, instead of corrupting it. - Fixed `Ctrl+T` (toggle thinking blocks) and the `/settings` "Hide Thinking Blocks" toggle only collapsing/expanding thinking in the live region: blocks that had scrolled into committed native scrollback on ED3-risk terminals kept their pre-toggle snapshot, so scrolling up showed the old thinking state. Both paths now `resetDisplay()` after flipping each block's flag, forcing a full clear + replay of the whole transcript (matching the tool-output expansion toggle) so every block above the fold re-renders at its new height. diff --git a/packages/coding-agent/src/hindsight/mental-models.ts b/packages/coding-agent/src/hindsight/mental-models.ts index eb0e3fc29..e4d3e8c60 100644 --- a/packages/coding-agent/src/hindsight/mental-models.ts +++ b/packages/coding-agent/src/hindsight/mental-models.ts @@ -387,7 +387,7 @@ export function diffMentalModelContent(previous: string | null, current: string, if (out.length > maxLines) { const dropped = out.length - maxLines; - return `${out.slice(0, maxLines).join("\n")}\n… ${dropped} more line${dropped === 1 ? "" : "s"} elided`; + return `${out.slice(0, maxLines).join("\n")}\n[…${dropped}ln elided…]`; } return out.join("\n"); } diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index c04e2f14c..d8cd381a8 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -2035,7 +2035,7 @@ export class LspTool implements AgentTool formatSymbolInformation(s, this.session.cwd)); const truncationLine = dedupedSymbols.length > WORKSPACE_SYMBOL_LIMIT - ? `\n... ${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} additional symbol(s) omitted` + ? `\n[…${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} symbols elided…]` : ""; return { content: [ diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index ffd74e4f6..00cf9d5b7 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -161,19 +161,28 @@ export class AgentTranscriptViewer implements Component { let signature: string; try { const stat = fs.statSync(sessionFile); - signature = `${stat.size}:${stat.mtimeMs}`; + // Include the path: a different file with the same size/mtime must not alias. + signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`; } catch { + // File deleted/rotated while open (e.g. the owning session was dropped): + // clear stale content once instead of freezing on it forever. + if (this.#lastSignature !== "missing") { + this.#lastSignature = "missing"; + this.#model = undefined; + this.#rebuild([]); + } return; } if (signature === this.#lastSignature) return; - this.#lastSignature = signature; let text: string; try { text = fs.readFileSync(sessionFile, "utf-8"); } catch (err) { + // Leave #lastSignature unchanged so a transient read error retries next poll. logger.debug("transcript viewer: read failed", { err: String(err) }); return; } + this.#lastSignature = signature; this.#model = undefined; this.#rebuild(this.#extractMessages(parseSessionEntries(text))); } diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index b452f59a4..1782559dc 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -33,7 +33,7 @@ Append `:` to `path`; bare path = default mode. - File with explicit selector → lines prefixed with numbers: `41|def alpha():`. {{/if}} {{/if}} -- Parseable code without selector → **structural summary**: declarations kept, bodies collapsed to `..` (merged brace pair) or `…` (standalone). The footer shows the recovery selector: `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]`. Re-issue ONLY the ranges you need via the multi-range selector. `..`/`…` carry no content — NEVER guess what's inside; NEVER re-read the whole file or `:raw` when ranges suffice. +- Parseable code without selector → **structural summary**: declarations kept, body elided with `…`. The footer shows the recovery selector. Re-issue ONLY the ranges you need via the multi-range selector. # Documents & Notebooks diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index b4aa18ab4..5eafeb528 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -495,8 +495,10 @@ export function truncateTail(content: string, options: TruncationOptions = {}): * Returned without surrounding newlines so callers can position it freely. */ export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string { - const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`; - return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`; + // A 0/1-line elision (e.g. one giant single line) would read as + // "[…0ln elided…]"; fall back to a byte count there. + if (elidedLines <= 1) return `[…${elidedBytes}B elided…]`; + return `[…${elidedLines}ln elided…]`; } /** @@ -578,8 +580,6 @@ export function truncateMiddle(content: string, options: TruncationOptions = {}) export interface InlineByteCapOptions { /** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */ maxBytes?: number; - /** What the text is, for the elision marker (e.g. "bash output"). */ - label: string; /** * Persist the full text as a session artifact. When an artifact id is * returned, a `[raw output: artifact://]` footer is appended so the @@ -619,7 +619,7 @@ export async function enforceInlineByteCap(text: string, options: InlineByteCapO const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text); const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text); const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8")); - const marker = `[… elided ${elidedBytes} bytes of ${options.label} …]`; + const marker = `[…${elidedBytes}B elided…]`; let composed = `${head}\n${marker}\n${tail}`; const artifactId = await options.saveArtifact?.(text); diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 327dabd13..141707f3a 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -485,7 +485,6 @@ export class BashTool implements AgentTool saveBashOriginalArtifact(this.session, full), }); diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index d703620d8..070bc99ee 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -332,7 +332,6 @@ export class BrowserTool implements AgentTool saveBrowserOutputArtifact(this.session, full), }); details.result = cappedText; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 1aa84fdd9..30791e8f1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -275,7 +275,7 @@ function formatMergedBraceLine( shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): { model: string; display: string } { - const merged = `${headText.trimEnd()} .. ${tailText.trim()}`; + const merged = `${headText.trimEnd()} … ${tailText.trim()}`; if (shouldAddHashLines) { return { model: `${startLine}-${endLine}:${merged}`, display: merged }; } @@ -315,7 +315,7 @@ const FOOTER_RANGE_SAMPLES = 2; /** * Footer appended to summarized reads telling the model how to recover the - * elided body. Without this hint, agents either ignore the `...`/`{ .. }` + * elided body. Without this hint, agents either ignore the `…`/`{ … }` * markers or burn a turn guessing the right selector (see issue #1046). The * footer demonstrates the multi-range selector syntax with concrete sample * ranges drawn from the actual elision so the model re-reads only what it @@ -327,7 +327,6 @@ function formatSummaryElisionFooter( elidedLines: number, ): string { if (elidedRanges.length === 0) return ""; - const lineWord = elidedLines === 1 ? "line" : "lines"; const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES); const selector = elidedRanges .slice(0, sampleCount) @@ -335,7 +334,7 @@ function formatSummaryElisionFooter( .join(","); const example = `${readPath}:${selector}`; const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`; - return `[${elidedLines} ${lineWord} elided; re-read needed ranges${tail}]`; + return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`; } const READ_CHUNK_SIZE = 8 * 1024; @@ -1904,8 +1903,8 @@ export class ReadTool implements AgentTool { let elidedLines = 0; for (const unit of units) { if (unit.kind === "elided") { - modelParts.push("..."); - displayParts.push("..."); + modelParts.push("…"); + displayParts.push("…"); elidedRanges.push({ start: unit.startLine, end: unit.endLine }); elidedLines += unit.endLine - unit.startLine + 1; continue; diff --git a/packages/coding-agent/src/workspace-tree.ts b/packages/coding-agent/src/workspace-tree.ts index b128a44e0..914926400 100644 --- a/packages/coding-agent/src/workspace-tree.ts +++ b/packages/coding-agent/src/workspace-tree.ts @@ -298,7 +298,7 @@ function applyLineCap( const removed = new Set(removable.map(item => item.index)); const kept = lines.filter((_, index) => !removed.has(index)); kept.push({ - label: `… (${removable.length} lines elided beyond depth/cap)`, + label: `[…${removable.length}ln elided…]`, depth: 0, isRoot: false, }); diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts index ccc47f2ea..5a52a4d38 100644 --- a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -140,4 +140,32 @@ describe("AgentTranscriptViewer", () => { expect(atBottom).not.toContain("PROMPTMARKER"); }); }); + + it("clears stale content when the transcript file is deleted while open", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, buildJsonl()); + const viewer = makeViewer(file); + const body = () => + viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + try { + viewer.render(80); + viewer.handleInput("g"); + expect(body()).toContain("PROMPTMARKER"); + + fs.rmSync(file); + // Poll until the viewer's own poll timer re-stats and clears (deadline-bounded). + const deadline = Date.now() + 5000; + while (body().includes("PROMPTMARKER") && Date.now() < deadline) { + await Bun.sleep(50); + } + expect(body()).not.toContain("PROMPTMARKER"); + } finally { + viewer.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/coding-agent/test/edit/file-snapshot-store.test.ts b/packages/coding-agent/test/edit/file-snapshot-store.test.ts index bd950ee82..eb829e4ee 100644 --- a/packages/coding-agent/test/edit/file-snapshot-store.test.ts +++ b/packages/coding-agent/test/edit/file-snapshot-store.test.ts @@ -72,17 +72,15 @@ describe("snapshot store fusion via canonical keys", () => { describe("parseSeenLinesFromHashlineBody", () => { it("collects single NN: line numbers and skips the header and footer rows", () => { - const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[2 lines elided; …]"].join( - "\n", - ); + const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[…2ln elided; …]"].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([300, 301, 302]); }); it("adds only the boundary lines of a collapsed NN-MM: summary row, never the interior", () => { const body = [ - "30-39:export interface Snapshot { .. }", + "30-39:export interface Snapshot { … }", "40:", - "46-61:export abstract class SnapshotStore { .. }", + "46-61:export abstract class SnapshotStore { … }", ].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([30, 39, 40, 46, 61]); }); @@ -92,7 +90,7 @@ describe("parseSeenLinesFromHashlineBody", () => { }); it("tolerates grep `*`/space match markers before the line number (search/ast-grep output)", () => { - const body = ["*73:matched line", " 74:context line", "...", " 75:more context"].join("\n"); + const body = ["*73:matched line", " 74:context line", "…", " 75:more context"].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([73, 74, 75]); }); }); diff --git a/packages/coding-agent/test/hindsight-mental-models.test.ts b/packages/coding-agent/test/hindsight-mental-models.test.ts index 14809629f..3187080ce 100644 --- a/packages/coding-agent/test/hindsight-mental-models.test.ts +++ b/packages/coding-agent/test/hindsight-mental-models.test.ts @@ -319,7 +319,7 @@ describe("diffMentalModelContent", () => { const out = diffMentalModelContent(null, big, 50); const lines = out.split("\n"); expect(lines.length).toBe(51); // 50 diff lines + 1 elision marker - expect(lines[lines.length - 1]).toMatch(/more lines? elided$/); + expect(lines[lines.length - 1]).toMatch(/\[…\d+ln elided…\]$/); }); it("caps LCS input lines so a huge curated model cannot hang the diff", () => { diff --git a/packages/coding-agent/test/read-summary.test.ts b/packages/coding-agent/test/read-summary.test.ts index c0c53ded5..0539b3fd9 100644 --- a/packages/coding-agent/test/read-summary.test.ts +++ b/packages/coding-agent/test/read-summary.test.ts @@ -72,8 +72,8 @@ describe("read summary", () => { const result = await tool.execute("read-summary-ts", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export function alpha(value: string): string { .. }"); - expect(text).toContain("export function beta(): number { .. }"); + expect(text).toContain("export function alpha(value: string): string { … }"); + expect(text).toContain("export function beta(): number { … }"); expect(text).not.toContain("const clean = value.trim()"); expect(result.details?.summary?.elidedSpans).toBe(2); }); @@ -124,7 +124,7 @@ describe("read summary", () => { const text = textOutput(result); expect(text).toContain("const clean = 'alpha';"); - expect(text).not.toContain("..."); + expect(text).not.toContain("…"); expect(result.details?.summary).toBeUndefined(); }); @@ -197,10 +197,10 @@ describe("read summary", () => { expect(text).toContain("name: Ada"); }); - it("renders brace-pair elisions as a single numbered line with `..`", async () => { + it("renders brace-pair elisions as a single numbered line with `…`", async () => { // Regression for the read-tool format request: collapse the head / // elided / closing-brace sandwich into one numbered line of the form - // `START-END:head { .. }` instead of three separate lines. + // `START-END:head { … }` instead of three separate lines. const fixture = path.join(tmpDir, "merge.ts"); await fs.writeFile( fixture, @@ -211,9 +211,9 @@ describe("read summary", () => { const result = await tool.execute("read-summary-merge", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { .. }"); - // The plain `...` ellipsis line must NOT appear once the merge fires. - expect(text).not.toContain("\n...\n"); + expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { … }"); + // The plain `…` ellipsis line must NOT appear once the merge fires. + expect(text).not.toContain("\n…\n"); // The merged line must use the numbered range shape. expect(text).toMatch(/\b1-7:export function stripNewLinePrefixes/); expect(result.details?.summary?.elidedSpans).toBe(1); @@ -229,8 +229,8 @@ describe("read summary", () => { const result = await tool.execute("read-summary-merge-trailing", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export const config = { .. };"); - expect(text).not.toContain("\n...\n"); + expect(text).toContain("export const config = { … };"); + expect(text).not.toContain("\n…\n"); }); it("does not merge when the closing line is not a bare brace", async () => { @@ -248,15 +248,15 @@ describe("read summary", () => { const text = textOutput(result); expect(text).toContain("def greet(name: str) -> str:"); - // Python's body elision keeps first/last body lines, so plain `...` + // Python's body elision keeps first/last body lines, so plain `…` // must remain as the elided segment. - expect(text).toContain("\n...\n"); - expect(text).not.toContain(" .. "); + expect(text).toContain("\n…\n"); + expect(text).not.toContain(" … "); }); it("appends an elision footer that names targeted recovery ranges", async () => { // Regression for issue #1046: summarized reads must tell the model how - // to recover the elided body so it does not stall on `...` / `{ .. }` + // to recover the elided body so it does not stall on `…` / `{ … }` // markers and burn a turn guessing the selector. const fixture = path.join(tmpDir, "footer.ts"); await fs.writeFile( @@ -270,7 +270,7 @@ describe("read summary", () => { expect(result.details?.summary?.elidedSpans).toBe(2); expect(result.details?.summary?.elidedLines).toBeGreaterThan(0); - expect(text).toContain("lines elided"); + expect(text).toContain("ln elided"); expect(text).toContain(`${fixture}:1-5,7-11`); expect(text).not.toContain(`${fixture}:raw`); expect(text).not.toContain(`${fixture}:1-9999`); diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index 27e549a86..5e1bb0f9f 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -54,7 +54,7 @@ function makeViewer(sessionFile: string, observed: ObservableSession[]): AgentTr registry: agents, observers: makeSubagentRegistry(observed), ui, - cwd: tmpDir, + cwd: path.dirname(sessionFile), expandKeys: ["ctrl+o"], hubKeys: ["ctrl+s"], requestRender: () => {}, @@ -117,7 +117,7 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const hub = makeHub(sessionFile, [ + const viewer = makeViewer(sessionFile, [ { id: SESSION_ID, kind: "subagent", @@ -128,10 +128,8 @@ describe("Agent hub silent-abort regression", () => { }, ]); - // Render with a reasonable width — the hub chat view reads the session - // file and calls #buildTranscriptLines internally. - const rendered = hub.render(120); - hub.dispose(); + const rendered = viewer.render(120); + viewer.dispose(); const renderedText = rendered.join("\n"); // The sentinel MUST NOT appear verbatim in any rendered line @@ -176,7 +174,7 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const hub = makeHub(sessionFile, [ + const viewer = makeViewer(sessionFile, [ { id: SESSION_ID, kind: "subagent", @@ -187,8 +185,8 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const rendered = hub.render(120); - hub.dispose(); + const rendered = viewer.render(120); + viewer.dispose(); const renderedText = rendered.join("\n"); // AssistantMessageComponent renders the error as "Error: " diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 1a682417e..eddda058c 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -560,9 +560,10 @@ describe("truncateMiddle", () => { expect(result.content).not.toContain("elided"); }); - test("formatMiddleElisionMarker pluralises and formats bytes", () => { - expect(formatMiddleElisionMarker(1, 100)).toBe("[… 1 line elided (100B) …]"); - expect(formatMiddleElisionMarker(123, 4096)).toBe("[… 123 lines elided (4.0KB) …]"); + test("formatMiddleElisionMarker uses lines, falling back to bytes for <=1 line", () => { + expect(formatMiddleElisionMarker(0, 512)).toBe("[…512B elided…]"); + expect(formatMiddleElisionMarker(1, 100)).toBe("[…100B elided…]"); + expect(formatMiddleElisionMarker(123, 4096)).toBe("[…123ln elided…]"); }); }); diff --git a/packages/coding-agent/test/tools/output-caps.test.ts b/packages/coding-agent/test/tools/output-caps.test.ts index 3c7997db6..feec2c115 100644 --- a/packages/coding-agent/test/tools/output-caps.test.ts +++ b/packages/coding-agent/test/tools/output-caps.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "bun:test"; import { DEFAULT_MAX_BYTES, enforceInlineByteCap } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; -const MARKER_PATTERN = /\[… elided \d+ bytes of test output …\]/; +const MARKER_PATTERN = /\[…\d+B elided…\]/; /** Build `count` complete lines of the form `line-00001 `. */ function makeLines(count: number, pad = "x".repeat(40)): string { @@ -22,24 +22,24 @@ function makeLines(count: number, pad = "x".repeat(40)): string { describe("enforceInlineByteCap", () => { it("returns sub-cap text unchanged (identity, not just equality)", async () => { const text = makeLines(10); - const result = await enforceInlineByteCap(text, { maxBytes: 4096, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes: 4096 }); expect(result).toBe(text); }); it("returns text exactly at the cap unchanged", async () => { const text = "a".repeat(1000); expect(Buffer.byteLength(text, "utf-8")).toBe(1000); - const result = await enforceInlineByteCap(text, { maxBytes: 1000, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes: 1000 }); expect(result).toBe(text); }); it("uses DEFAULT_MAX_BYTES when maxBytes is omitted", async () => { const under = "a".repeat(DEFAULT_MAX_BYTES - 1); - expect(await enforceInlineByteCap(under, { label: "test output" })).toBe(under); + expect(await enforceInlineByteCap(under, {})).toBe(under); const over = makeLines(2000); // ~94KB, well over the 50KB default expect(Buffer.byteLength(over, "utf-8")).toBeGreaterThan(DEFAULT_MAX_BYTES); - const result = await enforceInlineByteCap(over, { label: "test output" }); + const result = await enforceInlineByteCap(over, {}); expect(result).not.toBe(over); expect(result).toMatch(MARKER_PATTERN); expect(Buffer.byteLength(result, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES); @@ -48,7 +48,7 @@ describe("enforceInlineByteCap", () => { it("elides over-cap text to head + tail on line boundaries within the budget", async () => { const maxBytes = 4096; const text = makeLines(500); // ~23KB - const result = await enforceInlineByteCap(text, { maxBytes, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes }); expect(result).toMatch(MARKER_PATTERN); // Stays within the cap plus a sliver of slack for the marker line. @@ -80,7 +80,7 @@ describe("enforceInlineByteCap", () => { // Each line is multi-byte heavy: é (2B), € (3B), 😀 (4B). const text = makeLines(800, "é€😀".repeat(12)); const maxBytes = 4096; - const result = await enforceInlineByteCap(text, { maxBytes, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes }); expect(result).toMatch(MARKER_PATTERN); // Valid UTF-8 round-trip: encode/decode is lossless and introduces no @@ -102,7 +102,6 @@ describe("enforceInlineByteCap", () => { let saved: string | undefined; const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: full => { saved = full; return Promise.resolve("17"); @@ -118,7 +117,6 @@ describe("enforceInlineByteCap", () => { const text = makeLines(500); const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: () => undefined, }); expect(result).not.toContain("[raw output: artifact://"); @@ -130,7 +128,6 @@ describe("enforceInlineByteCap", () => { const text = "short output"; const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: () => { called = true; return "99"; diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index daaa8854f..76852c9cb 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -7,7 +7,7 @@ import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store"; import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector"; import type { ObservableSession, @@ -354,16 +354,20 @@ describe("tool path arrays", () => { status: "parked", }); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: ["ctrl+s"], - onDone: () => {}, - requestRender: () => {}, + const viewer = new AgentTranscriptViewer({ + agentId: "search-overlay-session", registry: agents, + observers, + ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, + cwd: tmp, + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, }); - hub.openChat("search-overlay-session"); - const rendered = Bun.stripANSI(hub.render(120).join("\n")); - hub.dispose(); + const rendered = Bun.stripANSI(viewer.render(120).join("\n")); + viewer.dispose(); // The hub chat now renders through searchToolRenderer.renderCall; the // single-string `paths` arg shows up as the "in " scope meta on the diff --git a/packages/coding-agent/test/tools/strip-output-notice.test.ts b/packages/coding-agent/test/tools/strip-output-notice.test.ts index d85b4e246..91c0fd9f1 100644 --- a/packages/coding-agent/test/tools/strip-output-notice.test.ts +++ b/packages/coding-agent/test/tools/strip-output-notice.test.ts @@ -45,7 +45,7 @@ const limitsOnly: OutputMeta = { describe("stripOutputNotice", () => { it("removes the exact notice appended by the wrapper for middle elision", () => { - const body = "line1\nline2\n[… 4 lines elided (215B) …]\nline7\nline8"; + const body = "line1\nline2\n[…4ln elided…]\nline7\nline8"; const notice = formatOutputNotice(truncation); const combined = body + notice; diff --git a/packages/coding-agent/test/workspace-tree.test.ts b/packages/coding-agent/test/workspace-tree.test.ts index c03423375..72cd0767d 100644 --- a/packages/coding-agent/test/workspace-tree.test.ts +++ b/packages/coding-agent/test/workspace-tree.test.ts @@ -120,7 +120,7 @@ describe("buildWorkspaceTree", () => { expect(tree.truncated).toBe(true); expect(tree.totalLines).toBeLessThanOrEqual(120); expect(renderedLines.length).toBeLessThanOrEqual(120); - expect(tree.rendered).toContain("lines elided beyond depth/cap"); + expect(tree.rendered).toContain("ln elided…]"); }); it("can keep root entries uncapped while truncating child directories", async () => { diff --git a/packages/snapcompact/CHANGELOG.md b/packages/snapcompact/CHANGELOG.md index 2655d47d8..e43922442 100644 --- a/packages/snapcompact/CHANGELOG.md +++ b/packages/snapcompact/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Changed +- Refined elision markers for file operations and truncated text for better display consistency - Updated summary text for consistent descriptions of archived tool output - Folded a much wider range of Unicode to ASCII in `normalize()` before native rendering: added a per-character Unicode NFKD decomposition fallback (fullwidth forms, super/subscripts, ligatures, circled and math-styled alphanumerics, Roman numerals, vulgar fractions) and expanded the `CHAR_FOLD` punctuation table (more quotes/primes, hyphens, the fraction slash, dot leaders, bullets, and arrows) so undrawable glyphs land on close ASCII equivalents instead of `?` diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 7103a9cc6..6b57b60b9 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -592,7 +592,7 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { - files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`; + files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } return prompt.render(fileOperationsTemplate, { files }); } @@ -657,7 +657,7 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number): const tailChars = maxChars - headChars; const elided = text.length - maxChars; const tail = tailChars > 0 ? text.slice(-tailChars) : ""; - return `${text.slice(0, headChars)} [… ${elided}ch elided …] ${tail}`; + return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`; } const DIM_MARKERS = /[\u000e\u000f]/g; diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 81b26f5c4..11a0723a3 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -489,7 +489,7 @@ describe("serializeConversation", () => { // Default cap 2000 at 0.6 head ratio: 1200 head + 800 tail survive. expect(out).toContain(""); expect(out).toContain("HEAD-"); - expect(out).toContain("[… 3010ch elided …]"); + expect(out).toContain("[…3010ch elided…]"); expect(out.endsWith(`-TAIL${snapcompact.DIM_OFF}\n`)).toBe(true); }); @@ -499,7 +499,7 @@ describe("serializeConversation", () => { toolResultMaxChars: 10, truncateHeadRatio: 0.5, }); - expect(tight).toContain("[… 90ch elided …]"); + expect(tight).toContain("[…90ch elided…]"); const off = snapcompact.serializeConversation([createToolResultMessage(text)], { toolResultMaxChars: Number.POSITIVE_INFINITY, }); @@ -514,7 +514,7 @@ describe("serializeConversation", () => { ]); // JSON-encoded content is 3002 chars; per-value cap 500 elides 2502. expect(out).toContain('write(path="a.ts", content='); - expect(out).toContain("[… 2502ch elided …]"); + expect(out).toContain("[…2502ch elided…]"); }); it("caps the whole serialized argument list per call", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 58bea1c40..e9eecfc84 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Fixed +- Corrected logic in string truncation to prevent improper truncation of short strings - Fixed a one-frame transcript flash during a non-multiplexer resize drag: while the drag borrowed the alternate screen and painted only the viewport, any ordinary (non-forced) render from a still-animating block — a tool spinner tick, a streamed token, a cursor blink — fell through to the deferred geometry-rebuild full paint, which left the alternate screen to repaint the whole transcript on the normal screen for a single frame before the next SIGWINCH re-entered the viewport fast path, so a live tool block flashed in and vanished. Ordinary renders mid-drag now stay on the viewport fast path; only forced renders (tool finalization, reset, image reconciliation) still preempt it. ## [16.0.10] - 2026-06-18 @@ -1730,4 +1730,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Fixed -- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) +- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) \ No newline at end of file diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 7e2d35183..426c63b45 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -90,7 +90,7 @@ export function truncateToWidth( const safeWidth = Number.isFinite(maxWidth) ? Math.max(0, Math.trunc(maxWidth)) : 0; // Fast path: every UTF-16 unit is at most 3 cells wide, so a string whose // `length * 3` already fits within `safeWidth` cannot need truncation. - if (!pad && text.length * 3<= safeWidth) { + if (!pad && text.length * 3 <= safeWidth) { return text; } let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind;