feat: standardized elision markers and improve transcript viewer robustness

- Standardized elision markers across all tool outputs and filters to use cohesive `[...N [type] elided...]`, `[...Nln elided...]`, and `[...xB elided...]` syntax.
- Updated documentation, prompts, and test expectations to reflect the unified elision format.
- Improved transcript viewer robustness by preventing content aliasing through path-inclusive signature hashing.
- Added logic to clear stale transcript content when associated session files are deleted, accompanied by verifying test cases.
This commit is contained in:
can1357
2026-06-19 04:35:18 +02:00
parent b6c099275d
commit c224ef4df5
50 changed files with 328 additions and 262 deletions
+9 -4
View File
@@ -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]
@@ -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]
+2 -2
View File
@@ -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)
}
@@ -474,7 +474,7 @@ fn compact_aws_generic(root: &Value) -> Option<String> {
let mut out =
compact_named_rows(&columns.iter().map(String::as_str).collect::<Vec<_>>(), &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>]) -> 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<String, Value>]) -> 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)
@@ -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}"
);
}
@@ -184,9 +184,9 @@ fn compact_format_json(input: &str) -> Option<String> {
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)
}
+1 -1
View File
@@ -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"));
}
+32 -48
View File
@@ -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<String>,
/// 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<LogEntry> {
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<LogEntry> {
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 (`<abs-path> <hash> [<branch>]`, 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 <a@x.com>\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
);
}
@@ -211,11 +211,11 @@ fn filter_release_list(input: &str) -> Option<String> {
// 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 ["));
+18 -2
View File
@@ -341,9 +341,9 @@ fn summarize_golangci_json(line: &str) -> Option<String> {
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]
+8 -9
View File
@@ -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<String> {
// 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);
@@ -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("... +"));
}
}
+9 -9
View File
@@ -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");
@@ -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]
@@ -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<String> {
}
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<String> {
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<String> {
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<String> {
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"));
}
+11 -11
View File
@@ -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<String>) -> Option<String> {
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]
@@ -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();
@@ -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<String>, rendered: &mut bool) {
@@ -157,14 +157,14 @@ fn render_rspec_blocks(out: &mut String, blocks: &mut Vec<String>, 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<String>, 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"));
}
@@ -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]
@@ -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]
+10 -10
View File
@@ -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::<Vec<_>>()
.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);
}
+1 -1
View File
@@ -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 `<files>` 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 `<files>` 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
<files>
+1 -1
View File
@@ -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. <path>: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. <path>: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)`.
+4 -1
View File
@@ -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).
+1 -1
View File
@@ -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 });
}
+4
View File
@@ -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.
@@ -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");
}
+1 -1
View File
@@ -2035,7 +2035,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
const lines = limitedSymbols.map(s => 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: [
@@ -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)));
}
@@ -33,7 +33,7 @@ Append `:<sel>` 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. <path>: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
@@ -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://<id>]` 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);
-1
View File
@@ -485,7 +485,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
// head-retention spill, minimizer miss) may emit more than
// ~DEFAULT_MAX_BYTES inline. No-op for already-bounded output.
const cappedOutputText = await enforceInlineByteCap(outputText, {
label: "bash output",
saveArtifact: full => saveBashOriginalArtifact(this.session, full),
});
@@ -332,7 +332,6 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
// text inline; the full text stays recoverable via the artifact footer
// when allocation succeeds.
const cappedText = await enforceInlineByteCap(textOnly, {
label: "browser output",
saveArtifact: full => saveBrowserOutputArtifact(this.session, full),
});
details.result = cappedText;
+5 -6
View File
@@ -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<typeof readSchema, ReadToolDetails> {
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;
+1 -1
View File
@@ -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,
});
@@ -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 });
}
});
});
@@ -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]);
});
});
@@ -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", () => {
+15 -15
View File
@@ -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`);
@@ -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: <message>"
@@ -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…]");
});
});
@@ -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 <pad>`. */
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";
@@ -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 <paths>" scope meta on the
@@ -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;
@@ -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 () => {
+2
View File
@@ -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 `?`
+2 -2
View File
@@ -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;
@@ -489,7 +489,7 @@ describe("serializeConversation", () => {
// Default cap 2000 at 0.6 head ratio: 1200 head + 800 tail survive.
expect(out).toContain("<out>");
expect(out).toContain("HEAD-");
expect(out).toContain("[… 3010ch elided …]");
expect(out).toContain("[…3010ch elided…]");
expect(out.endsWith(`-TAIL${snapcompact.DIM_OFF}\n</out>`)).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", () => {
+2 -2
View File
@@ -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))
+1 -1
View File
@@ -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;