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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 ["));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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("... +"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)`.
|
||||
|
||||
@@ -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).
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 `?`
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user