diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index 3a4106bb8..0d04966da 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -70,6 +70,12 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result = state.tree.chunks.iter().map(|c| c.path.clone()).collect(); + let initial_chunk_checksums: std::collections::HashMap = state + .tree + .chunks + .iter() + .map(|chunk| (chunk.path.clone(), chunk.checksum.clone())) + .collect(); let normalize_indent = params.normalize_indent.unwrap_or(true); let mut touched_paths = Vec::new(); let mut warnings = Vec::new(); @@ -286,6 +292,7 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result, touched_paths: &[String], + before_checksums: &std::collections::HashMap, normalize_indent: bool, ) -> String { - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; let show_leaf_preview = state.language == "tlaplus"; let focused_paths = compute_focus(state.tree(), touched_paths); let hunks = generate_diff_hunks(before, after, 0); - // Map each hunk to the chunk that should display it. - // Walk from the deepest containing chunk upward until we find one that - // has children (and therefore a closing tag in the tree output). let tree = state.tree(); let tab_replacement = if normalize_indent { NORMALIZED_TAB_REPLACEMENT @@ -1874,39 +1879,57 @@ fn render_changed_hunks( let lookup: HashMap<&str, &ChunkNode> = tree.chunks.iter().map(|c| (c.path.as_str(), c)).collect(); let render_indent = normalize_indent.then_some((file_indent_char, file_indent_step)); - let mut inline_hunks: HashMap> = HashMap::new(); - let mut orphan_hunks: Vec<&DiffHunk> = Vec::new(); + let mut changed_anchor_paths = HashSet::new(); for hunk in &hunks { - // Find the deepest chunk containing this hunk's new-file start line. let owner = crate::chunk::render::find_hunk_owner_chunk(tree, &lookup, hunk.new_start); - match owner { - Some(chunk_path) => { - let indent = crate::chunk::render::hunk_indent_for_chunk( - &lookup, - chunk_path, - state.source(), - tab_replacement, - render_indent, - ); - let mut lines = Vec::with_capacity(hunk.lines.len() + 1); - lines.push(format!("{indent}{}", hunk.header)); - for line in &hunk.lines { - let normalized = - render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step); - lines.push(format!("{indent}{normalized}")); - } - inline_hunks - .entry(chunk_path.to_owned()) - .or_default() - .push(crate::chunk::render::InlineHunk { lines }); - }, - None => orphan_hunks.push(hunk), + let owner_path = owner.unwrap_or(""); + let indent = if owner_path.is_empty() { + String::new() + } else { + crate::chunk::render::hunk_indent_for_chunk( + &lookup, + owner_path, + state.source(), + tab_replacement, + render_indent, + ) + }; + let mut lines = Vec::with_capacity(hunk.lines.len() + 1); + lines.push(format!("{indent}{}", hunk.header)); + for line in &hunk.lines { + let normalized = + render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step); + lines.push(format!("{indent}{normalized}")); + } + inline_hunks + .entry(owner_path.to_owned()) + .or_default() + .push(crate::chunk::render::InlineHunk { lines }); + } + + for path in touched_paths { + let mut current = Some(path.as_str()); + while let Some(chunk_path) = current { + if chunk_path.is_empty() { + break; + } + let Some(chunk) = lookup.get(chunk_path) else { + current = chunk_path.rfind('.').map(|dot| &chunk_path[..dot]); + continue; + }; + if before_checksums + .get(&chunk.path) + .is_none_or(|previous| previous != &chunk.checksum) + { + changed_anchor_paths.insert(chunk.path.clone()); + } + current = chunk.parent_path.as_deref(); } } - let tree_text = crate::chunk::render::render_state_with_hunks( + crate::chunk::render::render_state_with_hunks( state, &RenderParams { chunk_path: Some(String::new()), @@ -1922,31 +1945,12 @@ fn render_changed_hunks( focused_paths, }, inline_hunks, - ); - - if orphan_hunks.is_empty() { - return tree_text; - } - - // Append orphan hunks (not belonging to any named chunk) at the end. - let orphan_text = orphan_hunks - .iter() - .flat_map(|hunk| { - let mut lines = Vec::with_capacity(hunk.lines.len() + 1); - lines.push(hunk.header.clone()); - lines.extend(hunk.lines.iter().map(|line| { - render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step) - })); - lines - }) - .collect::>() - .join("\n"); - - format!("{tree_text}\n\n{orphan_text}") + changed_anchor_paths, + ) } -/// Build a focus list that includes touched chunks as Expanded, their -/// immediate siblings as Collapsed, and all ancestors as Container. +/// Build a focus list that includes touched chunks as Expanded and all +/// ancestors as Container. /// Falls back to no focus (full render) when more than 20 chunks were touched. fn compute_focus( tree: &crate::chunk::types::ChunkTree, @@ -2001,23 +2005,6 @@ fn compute_focus( .get(parent_path) .and_then(|p| p.parent_path.as_deref()); } - - // Immediate prev/next sibling -> Collapsed. - if let Some(parent_path) = chunk.parent_path.as_deref() - && let Some(parent) = lookup.get(parent_path) - && let Some(idx) = parent.children.iter().position(|p| p == path) - { - if idx > 0 { - focus - .entry(parent.children[idx - 1].clone()) - .or_insert(ChunkFocusMode::Collapsed); - } - if idx + 1 < parent.children.len() { - focus - .entry(parent.children[idx + 1].clone()) - .or_insert(ChunkFocusMode::Collapsed); - } - } } // Root chunk must always be Container so the walk starts. diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index 3af39651d..6c43c079e 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -3047,23 +3047,33 @@ end }) .expect("render_read should succeed"); - // The output should contain truncation markers indicating the chunk continues. + // The output should keep the chunk head and tail context and collapse the + // omitted middle ranges with generic expansion markers. assert!( - result.text.contains("to expand above"), - "should show top truncation marker when chunk extends above visible range: {}", + result.text.contains("1|function longFunc() {"), + "should keep the chunk signature when the visible range clips the head: {}", result.text ); assert!( - result.text.contains("to expand below"), - "should show bottom truncation marker when chunk extends below visible range: {}", + result.text.contains("9|let h = 8;"), + "should keep tail context when the visible range clips the body: {}", result.text ); - // The visible content should still be there. assert!( result.text.contains("let c = 3"), "visible content should be rendered: {}", result.text ); + assert!( + result.text.contains("[truncated… sel=L2-L2 to expand]"), + "should show a generic truncation marker above the requested lines: {}", + result.text + ); + assert!( + result.text.contains("[truncated… sel=L8-L8 to expand]"), + "should show a generic truncation marker below the requested lines: {}", + result.text + ); } #[test] @@ -3089,13 +3099,8 @@ end // Should NOT have any truncation markers. assert!( - !result.text.contains("to expand above"), - "no top clip marker when chunk fits: {}", - result.text - ); - assert!( - !result.text.contains("to expand below"), - "no bottom clip marker when chunk fits: {}", + !result.text.contains("[truncated…"), + "no clip marker when chunk fits: {}", result.text ); } diff --git a/crates/pi-natives/src/chunk/render.rs b/crates/pi-natives/src/chunk/render.rs index 9c7cefa72..c3180df51 100644 --- a/crates/pi-natives/src/chunk/render.rs +++ b/crates/pi-natives/src/chunk/render.rs @@ -28,6 +28,8 @@ env_uint! { static PREVIEW_TAIL_LINES: usize = "PI_CHUNK_PREVIEW_TAIL_LINES" or 4 => [1, usize::MAX]; } +const CLIPPED_TAIL_CONTEXT_LINES: u32 = 3; + fn normalize_rendered_line( line: &str, normalize_indent: Option<(char, usize)>, @@ -88,6 +90,26 @@ fn compute_fenced_code_lines(source_lines: &[&str], language: &str) -> HashSet String { + render_state_impl(state, params, HashMap::new(), HashSet::new(), false, false) +} + +pub fn render_state_with_hunks( + state: &ChunkStateInner, + params: &RenderParams, + inline_hunks: HashMap>, + changed_anchor_paths: HashSet, +) -> String { + render_state_impl(state, params, inline_hunks, changed_anchor_paths, true, true) +} + +fn render_state_impl( + state: &ChunkStateInner, + params: &RenderParams, + inline_hunks: HashMap>, + changed_anchor_paths: HashSet, + compact_meta: bool, + omit_anchor_line_counts: bool, +) -> String { let tree = state.tree(); let lookup = build_lookup(tree); let chunk_path = params @@ -124,6 +146,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { params.visible_range.as_ref(), params.render_children_only, params.show_leaf_preview, + &masked_source, &source_lines, tab_replacement, normalize_indent, @@ -139,6 +162,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { params.visible_range.as_ref(), params.render_children_only, params.show_leaf_preview, + &masked_source, &source_lines, tab_replacement, normalize_indent, @@ -167,21 +191,20 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { normalize_indent, fenced_lines, focus, - inline_hunks: HashMap::new(), + inline_hunks, + compact_meta, + omit_anchor_line_counts, + changed_anchor_paths, }; - push_line( - &mut ctx.out, - format!( - "{}| {}", - " ".repeat(num_width), - format_header_meta( - params.title.as_str(), - rendered_line_count, - params.language_tag.as_deref(), - chunk.checksum.as_str(), - params.omit_checksum, - ) + push_meta( + &mut ctx, + format_header_meta( + params.title.as_str(), + rendered_line_count, + params.language_tag.as_deref(), + chunk.checksum.as_str(), + params.omit_checksum, ), ); push_blank_meta(&mut ctx); @@ -196,6 +219,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { between_top_level_definitions: true, }); } + emit_inline_hunks_for(&mut ctx, ""); return ctx.out; } @@ -402,6 +426,12 @@ struct VisibleSpan { end: u32, } +#[derive(Clone, Copy)] +struct LineSegment { + start: u32, + end: u32, +} + fn intersect_visible_span( chunk: &ChunkNode, visible_range: Option<&VisibleLineRange>, @@ -428,8 +458,68 @@ enum LeafEntry { Ellipsis { start_abs: u32, end_abs: u32 }, } +fn clipped_head_context_line_count( + source: &str, + chunk: &ChunkNode, + preview_head_lines: usize, +) -> u32 { + let (head_lines, body_lines) = chunk_head_body_lines(source, chunk); + let fallback = preview_head_lines.max(1) as u32; + if head_lines == 0 { + return fallback; + } + if body_lines == 0 { + return head_lines.min(fallback); + } + head_lines +} + +fn push_segment(segments: &mut Vec, start: u32, end: u32) { + if start > end { + return; + } + if let Some(last) = segments.last_mut() + && start <= last.end.saturating_add(1) + { + last.end = last.end.max(end); + return; + } + segments.push(LineSegment { start, end }); +} + +fn clipped_leaf_segments( + source: &str, + chunk: &ChunkNode, + span: VisibleSpan, + preview_head_lines: usize, +) -> Vec { + let mut segments = Vec::new(); + if span.start > chunk.start_line { + let head_context_lines = clipped_head_context_line_count(source, chunk, preview_head_lines); + let preview_end = chunk + .start_line + .saturating_add(head_context_lines.saturating_sub(1)) + .min(chunk.end_line) + .min(span.start.saturating_sub(1)); + push_segment(&mut segments, chunk.start_line, preview_end); + } + push_segment(&mut segments, span.start, span.end); + if span.end < chunk.end_line { + let preview_start = chunk + .end_line + .saturating_add(1) + .saturating_sub(CLIPPED_TAIL_CONTEXT_LINES) + .max(chunk.start_line) + .max(span.end.saturating_add(1)); + push_segment(&mut segments, preview_start, chunk.end_line); + } + segments +} + fn build_leaf_entries( source_lines: &[&str], + source: &str, + chunk: &ChunkNode, span: VisibleSpan, tab_replacement: &str, normalize_indent: Option<(char, usize)>, @@ -441,23 +531,43 @@ fn build_leaf_entries( let low = span.start; let high = span.end; let visible_line_count = (high - low + 1) as usize; - let raw = (low..=high) - .map(|line| { - let normalize = if fenced_lines.contains(&line) { - None - } else { - normalize_indent - }; - LeafEntry::Line { - abs_line: line, - text: source_lines - .get(line.saturating_sub(1) as usize) - .map_or(String::new(), |text| { - normalize_rendered_line(text, normalize, tab_replacement) - }), + let make_line = |line: u32| { + let normalize = if fenced_lines.contains(&line) { + None + } else { + normalize_indent + }; + LeafEntry::Line { + abs_line: line, + text: source_lines + .get(line.saturating_sub(1) as usize) + .map_or(String::new(), |text| { + normalize_rendered_line(text, normalize, tab_replacement) + }), + } + }; + + if span.start > chunk.start_line || span.end < chunk.end_line { + let segments = clipped_leaf_segments(source, chunk, span, preview_head_lines); + let mut entries = Vec::new(); + let mut last_end: Option = None; + for segment in segments { + if let Some(previous_end) = last_end { + let gap_start = previous_end.saturating_add(1); + let gap_end = segment.start.saturating_sub(1); + if gap_start <= gap_end { + entries.push(LeafEntry::Ellipsis { start_abs: gap_start, end_abs: gap_end }); + } } - }) - .collect::>(); + for line in segment.start..=segment.end { + entries.push(make_line(line)); + } + last_end = Some(segment.end); + } + return entries; + } + + let raw = (low..=high).map(make_line).collect::>(); if visible_line_count <= full_display_threshold { return raw; @@ -531,6 +641,7 @@ fn for_each_rendered_source_line( lookup: &ChunkLookup<'_>, visible_range: Option<&VisibleLineRange>, show_leaf_preview: bool, + source: &str, source_lines: &[&str], tab_replacement: &str, normalize_indent: Option<(char, usize)>, @@ -555,6 +666,8 @@ fn for_each_rendered_source_line( if let Some(span) = span { for entry in build_leaf_entries( source_lines, + source, + chunk, span, tab_replacement, normalize_indent, @@ -572,8 +685,26 @@ fn for_each_rendered_source_line( } if let Some(span) = span { + if span.start > chunk.start_line { + let head_context_lines = + clipped_head_context_line_count(source, chunk, preview_head_lines); + let first_child_start = children + .first() + .map_or_else(|| chunk.end_line.saturating_add(1), |child| child.start_line); + let preview_end = chunk + .start_line + .saturating_add(head_context_lines.saturating_sub(1)) + .min(chunk.end_line) + .min(first_child_start.saturating_sub(1)) + .min(span.start.saturating_sub(1)); + for line in chunk.start_line..=preview_end { + if should_render_gap_line(tree, chunk, lookup, line) { + visit(line); + } + } + } let mut cursor = chunk.start_line; - for child in children { + for child in &children { let gap_end = child.start_line.saturating_sub(1); if gap_end >= cursor { for line in cursor..=gap_end { @@ -590,6 +721,7 @@ fn for_each_rendered_source_line( lookup, visible_range, show_leaf_preview, + source, source_lines, tab_replacement, normalize_indent, @@ -608,6 +740,21 @@ fn for_each_rendered_source_line( } } } + if span.end < chunk.end_line { + let last_child_end = children.last().map_or(0, |child| child.end_line); + let preview_start = chunk + .end_line + .saturating_add(1) + .saturating_sub(CLIPPED_TAIL_CONTEXT_LINES) + .max(chunk.start_line) + .max(last_child_end.saturating_add(1)) + .max(span.end.saturating_add(1)); + for line in preview_start..=chunk.end_line { + if should_render_gap_line(tree, chunk, lookup, line) { + visit(line); + } + } + } return; } @@ -618,6 +765,7 @@ fn for_each_rendered_source_line( lookup, visible_range, show_leaf_preview, + source, source_lines, tab_replacement, normalize_indent, @@ -637,6 +785,7 @@ fn compute_rendered_line_count( visible_range: Option<&VisibleLineRange>, render_children_only: bool, show_leaf_preview: bool, + source: &str, source_lines: &[&str], tab_replacement: &str, normalize_indent: Option<(char, usize)>, @@ -645,24 +794,23 @@ fn compute_rendered_line_count( preview_head_lines: usize, preview_tail_lines: usize, ) -> usize { - if let Some(range) = visible_range { - return (range.end_line - range.start_line + 1) as usize; - } - if render_children_only { - // Root reads render child chunks only, but the header should still report the - // file's true total line count. - return if chunk.path.is_empty() { - tree.line_count as usize - } else { - chunk.line_count as usize - }; - } - let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); - let has_out_of_span_child = children - .iter() - .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); - if !has_out_of_span_child { - return chunk.line_count as usize; + if visible_range.is_none() { + if render_children_only { + // Root reads render child chunks only, but the header should still report the + // file's true total line count. + return if chunk.path.is_empty() { + tree.line_count as usize + } else { + chunk.line_count as usize + }; + } + let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); + let has_out_of_span_child = children + .iter() + .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); + if !has_out_of_span_child { + return chunk.line_count as usize; + } } let mut rendered_lines = std::collections::BTreeSet::new(); for_each_rendered_source_line( @@ -671,6 +819,7 @@ fn compute_rendered_line_count( lookup, visible_range, show_leaf_preview, + source, source_lines, tab_replacement, normalize_indent, @@ -686,25 +835,28 @@ fn compute_rendered_line_count( } struct RenderCtx<'a> { - out: String, - tree: &'a ChunkTree, - lookup: &'a ChunkLookup<'a>, - source: &'a str, - source_lines: &'a [&'a str], - num_width: usize, - visible_range: Option<&'a VisibleLineRange>, - omit_checksum: bool, - anchor_style: ChunkAnchorStyle, - show_leaf_preview: bool, - last_was_blank_meta: bool, - full_display_threshold: usize, - preview_head_lines: usize, - preview_tail_lines: usize, - tab_replacement: &'a str, - normalize_indent: Option<(char, usize)>, - fenced_lines: HashSet, - focus: Option>, - inline_hunks: HashMap>, + out: String, + tree: &'a ChunkTree, + lookup: &'a ChunkLookup<'a>, + source: &'a str, + source_lines: &'a [&'a str], + num_width: usize, + visible_range: Option<&'a VisibleLineRange>, + omit_checksum: bool, + anchor_style: ChunkAnchorStyle, + show_leaf_preview: bool, + last_was_blank_meta: bool, + full_display_threshold: usize, + preview_head_lines: usize, + preview_tail_lines: usize, + tab_replacement: &'a str, + normalize_indent: Option<(char, usize)>, + fenced_lines: HashSet, + focus: Option>, + inline_hunks: HashMap>, + compact_meta: bool, + omit_anchor_line_counts: bool, + changed_anchor_paths: HashSet, } fn push_line(out: &mut String, line: String) { @@ -722,16 +874,26 @@ fn push_blank_meta(ctx: &mut RenderCtx<'_>) { ctx.last_was_blank_meta = true; } -fn push_meta(ctx: &mut RenderCtx<'_>, body: String) { +fn push_meta_marked(ctx: &mut RenderCtx<'_>, body: String, changed: bool) { ctx.last_was_blank_meta = false; - push_line(&mut ctx.out, format!("{}| {}", " ".repeat(ctx.num_width), body)); + let gutter = if changed { + format!("*{}", " ".repeat(ctx.num_width.saturating_sub(1))) + } else { + " ".repeat(ctx.num_width) + }; + let separator = if ctx.compact_meta { "|" } else { "| " }; + push_line(&mut ctx.out, format!("{gutter}{separator}{body}")); +} + +fn push_meta(ctx: &mut RenderCtx<'_>, body: String) { + push_meta_marked(ctx, body, false); } fn push_code(ctx: &mut RenderCtx<'_>, abs_line: u32, source_text: &str) { ctx.last_was_blank_meta = false; push_line( &mut ctx.out, - format!("{}| {}", abs_line.to_string().pad_start(ctx.num_width, ' '), source_text), + format!("{}|{}", abs_line.to_string().pad_start(ctx.num_width, ' '), source_text), ); } @@ -768,30 +930,85 @@ fn emit_line_gap(ctx: &mut RenderCtx<'_>, from: u32, to: u32) { } } -/// Emit a truncation marker when `visible_range` clips a chunk. -/// `above` = true for top clip, false for bottom clip. -fn emit_range_clip_marker( - ctx: &mut RenderCtx<'_>, - chunk: &ChunkNode, - span: &VisibleSpan, - above: bool, -) { - if ctx.visible_range.is_none() { - return; - } - let (hidden, direction, start, end) = if above { - let n = span.start.saturating_sub(chunk.start_line); - (n, "above", chunk.start_line, span.start.saturating_sub(1)) - } else { - let n = chunk.end_line.saturating_sub(span.end); - (n, "below", span.end + 1, chunk.end_line) - }; - if hidden == 0 { +fn push_truncation_marker(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, start: u32, end: u32) { + if start > end { return; } let indent = chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement, ctx.normalize_indent); - push_meta(ctx, format!("{indent}[truncated\u{2026} sel=L{start}-L{end} to expand {direction}]")); + push_meta(ctx, format!("{indent}[truncated\u{2026} sel=L{start}-L{end} to expand]")); +} + +fn emit_explicit_gap_lines(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, from: u32, to: u32) { + if from > to { + return; + } + for line in from..=to { + if !should_render_gap_line(ctx.tree, chunk, ctx.lookup, line) { + continue; + } + let normalize = if ctx.fenced_lines.contains(&line) { + None + } else { + ctx.normalize_indent + }; + let text = ctx + .source_lines + .get(line.saturating_sub(1) as usize) + .map_or(String::new(), |source_text| { + normalize_rendered_line(source_text, normalize, ctx.tab_replacement) + }); + push_code(ctx, line, &text); + } +} + +fn emit_container_clip_above( + ctx: &mut RenderCtx<'_>, + chunk: &ChunkNode, + span: &VisibleSpan, + children: &[&ChunkNode], +) { + if ctx.visible_range.is_none() || span.start <= chunk.start_line { + return; + } + let head_context_lines = + clipped_head_context_line_count(ctx.source, chunk, ctx.preview_head_lines); + let first_child_start = children + .first() + .map_or_else(|| chunk.end_line.saturating_add(1), |child| child.start_line); + let preview_end = chunk + .start_line + .saturating_add(head_context_lines.saturating_sub(1)) + .min(chunk.end_line) + .min(first_child_start.saturating_sub(1)) + .min(span.start.saturating_sub(1)); + if preview_end >= chunk.start_line { + emit_explicit_gap_lines(ctx, chunk, chunk.start_line, preview_end); + } + push_truncation_marker(ctx, chunk, preview_end.saturating_add(1), span.start.saturating_sub(1)); +} + +fn emit_container_clip_below( + ctx: &mut RenderCtx<'_>, + chunk: &ChunkNode, + span: &VisibleSpan, + children: &[&ChunkNode], +) { + if ctx.visible_range.is_none() || span.end >= chunk.end_line { + return; + } + let last_child_end = children.last().map_or(0, |child| child.end_line); + let preview_start = chunk + .end_line + .saturating_add(1) + .saturating_sub(CLIPPED_TAIL_CONTEXT_LINES) + .max(chunk.start_line) + .max(last_child_end.saturating_add(1)) + .max(span.end.saturating_add(1)); + push_truncation_marker(ctx, chunk, span.end.saturating_add(1), preview_start.saturating_sub(1)); + if preview_start <= chunk.end_line { + emit_explicit_gap_lines(ctx, chunk, preview_start, chunk.end_line); + } } fn virtual_render_lines( @@ -819,6 +1036,8 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, span: VisibleSpan) for entry in build_leaf_entries( ctx.source_lines, + ctx.source, + chunk, span, ctx.tab_replacement, ctx.normalize_indent, @@ -830,16 +1049,37 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, span: VisibleSpan) match entry { LeafEntry::Line { abs_line, text } => push_code(ctx, abs_line, &text), LeafEntry::Ellipsis { start_abs, end_abs, .. } => { - push_meta(ctx, format!("[truncated\u{2026} sel=L{start_abs}-L{end_abs} to expand]")); + push_truncation_marker(ctx, chunk, start_abs, end_abs); }, } } } -/// Emit any inline diff hunks mapped to the given chunk path. +fn render_open_anchor_line(ctx: &RenderCtx<'_>, chunk: &ChunkNode) -> String { + let anchor_indent = + chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement, ctx.normalize_indent); + let (head_lines, body_lines) = chunk_head_body_lines(ctx.source, chunk); + let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum); + let anchor_label = chunk_anchor_label(chunk, style); + if ctx.omit_anchor_line_counts { + style.render_without_counts(&anchor_indent, anchor_label.as_str(), chunk.checksum.as_str()) + } else { + style.render( + &anchor_indent, + anchor_label.as_str(), + chunk.checksum.as_str(), + head_lines, + body_lines, + ) + } +} + fn emit_inline_hunks_for(ctx: &mut RenderCtx<'_>, chunk_path: &str) { - let lines: Vec = match ctx.inline_hunks.get(chunk_path) { - Some(hunks) => hunks.iter().flat_map(|h| h.lines.iter().cloned()).collect(), + let lines = match ctx.inline_hunks.get(chunk_path) { + Some(hunks) => hunks + .iter() + .flat_map(|hunk| hunk.lines.iter().cloned()) + .collect::>(), None => return, }; for line in lines { @@ -869,24 +1109,10 @@ fn emit_chunk_subtree( if options.between_top_level_definitions && depth == 0 && !options.is_first_top_level { push_blank_meta(ctx); } - let anchor_indent = chunk_body_anchor_indent( - ctx.source_lines, - chunk, - ctx.tab_replacement, - ctx.normalize_indent, - ); - let (head_lines, body_lines) = chunk_head_body_lines(ctx.source, chunk); - let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum); - let anchor_label = chunk_anchor_label(chunk, style); - push_meta( + push_meta_marked( ctx, - style.render( - &anchor_indent, - anchor_label.as_str(), - chunk.checksum.as_str(), - head_lines, - body_lines, - ), + render_open_anchor_line(ctx, chunk), + ctx.changed_anchor_paths.contains(chunk.path.as_str()), ); return; }, @@ -909,39 +1135,22 @@ fn emit_chunk_subtree( push_blank_meta(ctx); } if !chunk.path.is_empty() { - let anchor_indent = chunk_body_anchor_indent( - ctx.source_lines, - chunk, - ctx.tab_replacement, - ctx.normalize_indent, - ); - let (head_lines, body_lines) = chunk_head_body_lines(ctx.source, chunk); - let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum); - let anchor_label = chunk_anchor_label(chunk, style); - push_meta( + push_meta_marked( ctx, - style.render( - &anchor_indent, - anchor_label.as_str(), - chunk.checksum.as_str(), - head_lines, - body_lines, - ), + render_open_anchor_line(ctx, chunk), + ctx.changed_anchor_paths.contains(chunk.path.as_str()), ); } if !has_kids { + if !chunk.path.is_empty() && ctx.inline_hunks.contains_key(chunk.path.as_str()) { + emit_inline_hunks_for(ctx, &chunk.path); + return; + } if ctx.show_leaf_preview && let Some(span) = span { - emit_range_clip_marker(ctx, chunk, &span, true); emit_leaf_body(ctx, chunk, span); - emit_range_clip_marker(ctx, chunk, &span, false); - } - // Emit inline diff hunks even when children are filtered out by - // focus (the chunk is "effectively leaf" but may own hunks). - if !chunk.path.is_empty() { - emit_inline_hunks_for(ctx, &chunk.path); } return; } @@ -954,10 +1163,9 @@ fn emit_chunk_subtree( == Some(ChunkFocusMode::Container); if let Some(span) = span { - // Top clip marker for container chunks - emit_range_clip_marker(ctx, chunk, &span, true); + emit_container_clip_above(ctx, chunk, &span, &children); let mut cursor = chunk.start_line; - for child in children { + for child in &children { let gap_end = child.start_line.saturating_sub(1); if gap_end >= cursor && !is_container { for line in cursor..=gap_end { @@ -977,9 +1185,7 @@ fn emit_chunk_subtree( if cursor <= span.end && !is_container { emit_line_gap(ctx, cursor, span.end); } - // Bottom clip marker for container chunks - emit_range_clip_marker(ctx, chunk, &span, false); - // Emit inline diff hunks before the closing tag. + emit_container_clip_below(ctx, chunk, &span, &children); if !chunk.path.is_empty() { emit_inline_hunks_for(ctx, &chunk.path); } @@ -1015,6 +1221,7 @@ fn compute_num_width( visible_range: Option<&VisibleLineRange>, render_children_only: bool, show_leaf_preview: bool, + source: &str, source_lines: &[&str], tab_replacement: &str, normalize_indent: Option<(char, usize)>, @@ -1023,18 +1230,17 @@ fn compute_num_width( preview_head_lines: usize, preview_tail_lines: usize, ) -> usize { - if let Some(range) = visible_range { - return range.end_line.to_string().len().max(1); - } - if render_children_only { - return tree.line_count.to_string().len().max(1); - } - let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); - let has_out_of_span_child = children - .iter() - .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); - if !has_out_of_span_child { - return chunk.end_line.to_string().len().max(1); + if visible_range.is_none() { + if render_children_only { + return tree.line_count.to_string().len().max(1); + } + let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); + let has_out_of_span_child = children + .iter() + .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); + if !has_out_of_span_child { + return chunk.end_line.to_string().len().max(1); + } } let mut max_line = 1usize; for_each_rendered_source_line( @@ -1043,6 +1249,7 @@ fn compute_num_width( lookup, visible_range, show_leaf_preview, + source, source_lines, tab_replacement, normalize_indent, @@ -1057,36 +1264,16 @@ fn compute_num_width( max_line.to_string().len().max(1) } -/// Find the chunk that should own a diff hunk for inline display. -/// -/// Walks from the deepest chunk containing `line` upward until it finds a -/// chunk with children (which will have a closing tag in the tree output). -/// Returns the chunk path, or `None` for file-root orphans. +/// Find the deepest visible chunk that should own a diff hunk for inline +/// display. pub fn find_hunk_owner_chunk<'a>( tree: &'a ChunkTree, - lookup: &ChunkLookup<'a>, + _lookup: &ChunkLookup<'a>, line: u32, ) -> Option<&'a str> { - let deepest = line_to_containing_chunk(tree, line)?; - // If the deepest chunk has children, it gets a closing tag — use it. - if !deepest.children.is_empty() { - return Some(&deepest.path); - } - // Leaf: promote to parent (which has children and a closing tag). - if let Some(parent_path) = deepest.parent_path.as_deref() - && let Some(parent) = lookup.get(parent_path) - && !parent.path.is_empty() - { - return Some(&parent.path); - } - // Root-level leaf — no parent with a closing tag. - None + line_to_containing_chunk(tree, line).map(|chunk| chunk.path.as_str()) } -/// Compute the indentation string for inline hunks placed inside a chunk. -/// -/// Uses the chunk's own body anchor indent plus one additional level, which -/// aligns hunks at the same depth as the chunk's child anchors. pub fn hunk_indent_for_chunk( lookup: &ChunkLookup<'_>, chunk_path: &str, @@ -1104,141 +1291,3 @@ pub fn hunk_indent_for_chunk( None => format!("{base}{tab_replacement}"), } } - -/// Render a chunk tree with diff hunks inlined into their owning chunk blocks. -pub fn render_state_with_hunks( - state: &ChunkStateInner, - params: &RenderParams, - inline_hunks: HashMap>, -) -> String { - let tree = state.tree(); - let lookup = build_lookup(tree); - let chunk_path = params - .chunk_path - .as_deref() - .unwrap_or(tree.root_path.as_str()); - let Some(chunk) = get_chunk(&lookup, chunk_path) else { - return String::new(); - }; - let masked_source = mask_chunk_display_source(state.source(), state.language()); - let source_lines = masked_source.split('\n').collect::>(); - let full_display_threshold = *FULL_DISPLAY_THRESHOLD; - let preview_head_lines = *PREVIEW_HEAD_LINES; - let preview_tail_lines = *PREVIEW_TAIL_LINES; - let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" "); - let normalize_indent = params.normalize_indent.unwrap_or(false).then(|| { - ( - detect_file_indent_char(state.source(), tree), - detect_file_indent_step(state.source(), tree) as usize, - ) - }); - - let fenced_lines = compute_fenced_code_lines(&source_lines, &tree.language); - - let anchor_style = params.anchor_style.unwrap_or_default(); - let focus: Option> = params - .focused_paths - .as_ref() - .map(|paths| paths.iter().map(|fp| (fp.path.as_str(), fp.mode)).collect()); - let num_width = compute_num_width( - tree, - chunk, - &lookup, - params.visible_range.as_ref(), - params.render_children_only, - params.show_leaf_preview, - &source_lines, - tab_replacement, - normalize_indent, - &fenced_lines, - full_display_threshold, - preview_head_lines, - preview_tail_lines, - ); - let rendered_line_count = compute_rendered_line_count( - tree, - chunk, - &lookup, - params.visible_range.as_ref(), - params.render_children_only, - params.show_leaf_preview, - &source_lines, - tab_replacement, - normalize_indent, - &fenced_lines, - full_display_threshold, - preview_head_lines, - preview_tail_lines, - ); - - let mut ctx = RenderCtx { - out: String::new(), - tree, - lookup: &lookup, - source: &masked_source, - source_lines: &source_lines, - num_width, - visible_range: params.visible_range.as_ref(), - omit_checksum: params.omit_checksum, - anchor_style, - show_leaf_preview: params.show_leaf_preview, - last_was_blank_meta: false, - full_display_threshold, - preview_head_lines, - preview_tail_lines, - tab_replacement, - normalize_indent, - fenced_lines, - focus, - inline_hunks, - }; - - push_line( - &mut ctx.out, - format!( - "{}| {}", - " ".repeat(num_width), - format_header_meta( - params.title.as_str(), - rendered_line_count, - params.language_tag.as_deref(), - chunk.checksum.as_str(), - params.omit_checksum, - ) - ), - ); - push_blank_meta(&mut ctx); - - if params.render_children_only { - let focus_ref = ctx.focus.as_ref(); - let children = - visible_children_for_chunk(tree, chunk, &lookup, params.visible_range.as_ref(), focus_ref); - for (index, child) in children.iter().enumerate() { - emit_chunk_subtree(&mut ctx, child, 0, ChunkSubtreeOptions { - is_first_top_level: index == 0, - between_top_level_definitions: true, - }); - } - // Emit any hunks mapped to the root (empty path). - emit_inline_hunks_for(&mut ctx, ""); - return ctx.out; - } - - if chunk.children.is_empty() && ctx.focus.is_none() { - if params.show_leaf_preview - && intersect_visible_span(chunk, params.visible_range.as_ref()).is_some() - { - emit_chunk_subtree(&mut ctx, chunk, 0, ChunkSubtreeOptions { - is_first_top_level: true, - between_top_level_definitions: false, - }); - } - return ctx.out; - } - - emit_chunk_subtree(&mut ctx, chunk, 0, ChunkSubtreeOptions { - is_first_top_level: true, - between_top_level_definitions: false, - }); - ctx.out -} diff --git a/crates/pi-natives/src/chunk/state.rs b/crates/pi-natives/src/chunk/state.rs index 309f486c1..7df1156cb 100644 --- a/crates/pi-natives/src/chunk/state.rs +++ b/crates/pi-natives/src/chunk/state.rs @@ -412,8 +412,8 @@ impl ChunkState { end_line: visible_range.end_line.min(self.inner.tree().line_count), }; let notice = format!( - "[Notice: chunk view scoped to requested lines L{}-L{}; non-overlapping lines \ - omitted.]", + "[Notice: chunk view scoped to requested lines L{}-L{}; clipped chunks keep head/tail \ + context and collapse non-overlapping children.]", clamped_range.start_line, clamped_range.end_line ); let text = self.render(RenderParams { diff --git a/crates/pi-natives/src/chunk/types.rs b/crates/pi-natives/src/chunk/types.rs index a2e219559..705491349 100644 --- a/crates/pi-natives/src/chunk/types.rs +++ b/crates/pi-natives/src/chunk/types.rs @@ -261,6 +261,11 @@ impl ChunkAnchorStyle { self.render_i(("[<", ">]"), indent, name, crc, &suffix) } + /// Render an opening anchor tag without the `(N lns)` suffix. + pub fn render_without_counts(&self, indent: &str, name: &str, crc: &str) -> String { + self.render_i(("[<", ">]"), indent, name, crc, "") + } + /// Render a closing anchor tag: `[]`. /// Returns empty string for `None` style. pub fn render_close(&self, indent: &str, name: &str, crc: &str) -> String { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e59874145..f262f9dc2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added support for `computeHashlineDiff` to accept hashline edits with `loc` and `content` payloads without requiring pre-resolved `op` fields @@ -10,10 +9,13 @@ ### Changed +- Grouped chunk-mode `grep` results by directory, file, and chunk so directory searches now render as hierarchical sections (`#`/`##`) with per-chunk anchor lines +- Updated chunk-mode `grep` output to include match lines under their containing chunk entries with consistent line-number alignment based on file length - Changed eager todo enforcement to only apply on the first user message of a conversation, skipping subsequent user turns that may correct, clarify, or redirect the prior task ### Fixed +- Fixed chunk streaming output detection to verify chunk edits with `chunkToolEditSchema`, preventing non-chunk edit payloads from being rendered as chunk diffs - Fixed tool execution output to return the original `toolResult` text content from tools instead of sanitizing it before sending completion messages - Fixed session accent rendering in the status line and editor to reset only foreground color (`\x1b[39m`) so applying a session color no longer clears other ANSI styles - Session name sanitization: strip C0/C1 control characters (including ANSI ESC) from session names at storage time and in status line rendering, preventing escape sequence injection into TUI output diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index 971a95a5f..c78b8189c 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -257,16 +257,34 @@ export async function formatChunkedRead(params: { return { text: result.text, resolvedPath: filePath, chunk: result.chunk }; } -export async function formatChunkedGrepLine(params: { +export type ChunkedGrepMatch = { + displayPath: string; + fileLineCount: number; + chunkPath?: string; + chunkChecksum?: string; + lineNumber: number; + line: string; +}; + +export async function describeChunkedGrepMatch(params: { filePath: string; lineNumber: number; line: string; cwd: string; language?: string; -}): Promise { +}): Promise { const { filePath, lineNumber, line, cwd, language } = params; const { state } = await loadChunkStateForFile(filePath, language); - return state.formatGrepLine(displayPathForFile(filePath, cwd), lineNumber, line); + const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined; + const chunkInfo = chunkPath ? state.chunk(chunkPath) : null; + return { + displayPath: displayPathForFile(filePath, cwd), + fileLineCount: state.lineCount, + chunkPath, + chunkChecksum: chunkInfo?.checksum, + lineNumber, + line, + }; } const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH"; diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 46aab0eca..937b0e840 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -4,6 +4,7 @@ import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; +import { Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { FileDiagnosticsResult } from "../lsp"; import { renderDiff as renderDiffColored } from "../modes/components/diff"; @@ -23,7 +24,7 @@ import { } from "../tools/render-utils"; import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import type { DiffError, DiffResult } from "./diff"; -import type { ChunkToolEdit } from "./modes/chunk"; +import { type ChunkToolEdit, chunkToolEditSchema } from "./modes/chunk"; import type { HashlineToolEdit } from "./modes/hashline"; import type { Operation } from "./modes/patch"; @@ -182,7 +183,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe } function isChunkStreamingEdit(edit: Partial): edit is Partial { - return "sel" in edit; + return Type.Partial(chunkToolEditSchema).Check(edit); } function getStreamingEditContent(content: unknown): string { diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index 174e8f941..ad5b198ca 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -71,52 +71,52 @@ Given this `read` output for `example.ts`: | example.ts·34L·ts·#QBMH | | [] - 1| interface Config { + 1|interface Config { | [] - 2| host: string; + 2| host: string; | [] - 3| port: number; + 3| port: number; | [] - 4| debug: boolean; - 5| } + 4| debug: boolean; + 5|} | | [] - 7| class Counter { + 7|class Counter { | [] - 8| value: number = 0; + 8| value: number = 0; 9| | [] -10| increment(): void { -11| this.value += 1; -12| } +10| increment(): void { +11| this.value += 1; +12| } 13| | [] -14| decrement(): void { -15| this.value -= 1; -16| } +14| decrement(): void { +15| this.value -= 1; +16| } 17| | [] -18| toString(): string { -19| return `Counter(${this.value})`; -20| } -21| } +18| toString(): string { +19| return `Counter(${this.value})`; +20| } +21|} | | [] -23| enum Status { +23|enum Status { | [] -24| Active = "ACTIVE", +24| Active = "ACTIVE", | [] -25| Paused = "PAUSED", +25| Paused = "PAUSED", | [] -26| Stopped = "STOPPED", -27| } +26| Stopped = "STOPPED", +27|} | | [] -29| function createCounter(initial: number): Counter { -30| const counter = new Counter(); -31| counter.value = initial; -32| return counter; -33| } +29|function createCounter(initial: number): Counter { +30| const counter = new Counter(); +31| counter.value = initial; +32| return counter; +33|} ``` **Replace a whole chunk** (rename a function): diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index a28f72ff2..7f92a6851 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -7,7 +7,7 @@ import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { computeLineHash } from "../edit/line-hash"; -import { formatChunkedGrepLine } from "../edit/modes/chunk"; +import { type ChunkedGrepMatch, describeChunkedGrepMatch } from "../edit/modes/chunk"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; @@ -274,13 +274,11 @@ export class GrepTool implements AgentTool { matchesByFile.get(relativePath)!.push(match); } if (chunkMode) { - const annotatedLines = await Promise.all( + const annotatedMatches = await Promise.all( selectedMatches.map(match => { const relativePath = match.path.startsWith("/") ? match.path.slice(1) : match.path; const absoluteFilePath = isDirectory ? path.join(searchPath, relativePath) : searchPath; - const displayPath = formatPath(match.path); - fileMatchCounts.set(displayPath, (fileMatchCounts.get(displayPath) ?? 0) + 1); - return formatChunkedGrepLine({ + return describeChunkedGrepMatch({ filePath: absoluteFilePath, lineNumber: match.lineNumber, line: match.line, @@ -289,7 +287,75 @@ export class GrepTool implements AgentTool { }); }), ); - const rawOutput = annotatedLines.join("\n"); + const chunkMatchesByFile = new Map(); + for (const match of annotatedMatches) { + recordFile(match.displayPath); + if (!chunkMatchesByFile.has(match.displayPath)) { + chunkMatchesByFile.set(match.displayPath, []); + } + chunkMatchesByFile.get(match.displayPath)!.push(match); + } + const renderChunkedMatchesForFile = (relativePath: string) => { + const fileMatches = chunkMatchesByFile.get(relativePath) ?? []; + if (fileMatches.length === 0) { + return; + } + const lineWidth = fileMatches[0]?.fileLineCount.toString().length ?? 1; + const matchesByChunk = new Map(); + for (const match of fileMatches) { + const chunkKey = match.chunkPath ?? ""; + if (!matchesByChunk.has(chunkKey)) { + matchesByChunk.set(chunkKey, []); + } + matchesByChunk.get(chunkKey)!.push(match); + } + for (const [chunkPath, chunkMatches] of matchesByChunk) { + if (chunkPath) { + const chunkChecksum = chunkMatches[0]?.chunkChecksum; + const anchor = chunkChecksum ? `[<${chunkPath}#${chunkChecksum}>]` : `[<${chunkPath}>]`; + outputLines.push(` ${anchor}`); + } + for (const match of chunkMatches) { + outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")}|${match.line}`); + fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1); + } + } + }; + if (isDirectory) { + const filesByDirectory = new Map(); + for (const relativePath of fileList) { + const directory = path.dirname(relativePath).replace(/\\/g, "/"); + if (!filesByDirectory.has(directory)) { + filesByDirectory.set(directory, []); + } + filesByDirectory.get(directory)!.push(relativePath); + } + for (const [directory, directoryFiles] of filesByDirectory) { + if (directory === ".") { + for (const relativePath of directoryFiles) { + if (outputLines.length > 0) { + outputLines.push(""); + } + outputLines.push(`# ${path.basename(relativePath)}`); + renderChunkedMatchesForFile(relativePath); + } + continue; + } + if (outputLines.length > 0) { + outputLines.push(""); + } + outputLines.push(`# ${directory}`); + for (const relativePath of directoryFiles) { + outputLines.push(`## └─ ${path.basename(relativePath)}`); + renderChunkedMatchesForFile(relativePath); + } + } + } else { + for (const relativePath of fileList) { + renderChunkedMatchesForFile(relativePath); + } + } + const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated); const details: GrepToolDetails = { diff --git a/packages/coding-agent/test/core/chunk-tree.test.ts b/packages/coding-agent/test/core/chunk-tree.test.ts index 87f8498c1..932c6a515 100644 --- a/packages/coding-agent/test/core/chunk-tree.test.ts +++ b/packages/coding-agent/test/core/chunk-tree.test.ts @@ -771,8 +771,8 @@ describe("formatChunkedRead", () => { expect(result.text).toContain("worker.ts:class_Worker.fn_run·"); expect(result.text).toContain("class_Worker.fn_run#"); - expect(result.text).toContain("6| \trun(): void {"); - expect(result.text).toContain("7| \t\tconsole.log(this.name);"); + expect(result.text).toContain("6|\trun(): void {"); + expect(result.text).toContain("7|\t\tconsole.log(this.name);"); expect(result.text).toContain("console.log(this.name);"); }); @@ -792,8 +792,8 @@ describe("formatChunkedRead", () => { expect(result.text).not.toContain("to expand ⋮"); expectRenderedChunkPath(result.text, "service.ts:class_Service.fn_handle", "·"); - expect(result.text).toContain("3| \t\tstep(0);"); - expect(result.text).toContain("27| \t\tstep(24);"); + expect(result.text).toContain("3|\t\tstep(0);"); + expect(result.text).toContain("27|\t\tstep(24);"); expect(result.text).toContain("done();"); }); }); @@ -901,8 +901,8 @@ describe("addressable member rendering", () => { }); expectRenderedChunkPath(result.text, "type_Handler"); - expect(result.text).toContain("3| type Handler interface {"); - expect(result.text).toContain("4| \tHandle(method, path string) Result"); + expect(result.text).toContain("3|type Handler interface {"); + expect(result.text).toContain("4|\tHandle(method, path string) Result"); }); test("renders Go receiver methods as top-level siblings", async () => { diff --git a/packages/coding-agent/test/tools/chunk-mode.test.ts b/packages/coding-agent/test/tools/chunk-mode.test.ts index fbac8c00b..54c28bb9a 100644 --- a/packages/coding-agent/test/tools/chunk-mode.test.ts +++ b/packages/coding-agent/test/tools/chunk-mode.test.ts @@ -151,7 +151,7 @@ describe("chunk mode tools", () => { expect(text).not.toContain("to expand ⋮"); expect(text).toContain(`server.ts:${HANDLE_ERROR_CHUNK_PATH}·`); expect(text).toContain("let total = 0;"); - expect(text).toContain("29| \t\t\ttotal +="); + expect(text).toContain("29|\t\t\ttotal +="); expect(text).toContain("return err.message + total;"); }); @@ -166,11 +166,11 @@ describe("chunk mode tools", () => { }); const text = getText(result); - expect(text).toContain("2| private handleError(err: Error): string {"); - expect(text).toContain("3| let total = 0;"); - expect(text).toContain("29| total +="); - expect(text).not.toContain("2| \tprivate handleError"); - expect(text).not.toContain("29| \t\t\ttotal +="); + expect(text).toContain("2| private handleError(err: Error): string {"); + expect(text).toContain("3| let total = 0;"); + expect(text).toContain("29| total +="); + expect(text).not.toContain("2|\tprivate handleError"); + expect(text).not.toContain("29|\t\t\ttotal +="); }); it("renders line-range reads as range-scoped chunk output", async () => { @@ -181,13 +181,16 @@ describe("chunk mode tools", () => { const result = await tool.execute("chunk-read-lines", { path: filePath, sel: "L2-L4" }); const text = getText(result); - expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]"); + expect(text).toContain( + "[Notice: chunk view scoped to requested lines L2-L4; clipped chunks keep head/tail context and collapse non-overlapping children.]", + ); expect(text).toContain("server.ts·"); expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}#`); expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}.var_total#`); expect(text).toContain("3|"); expect(text).toContain("4|"); - expect(text).not.toContain("⋯"); + expect(text).toContain("64|\t\treturn err.message + total;"); + expect(text).toContain("[truncated… sel=L5-L62 to expand]"); expect(text).not.toContain("to expand above"); expect(text).not.toContain("to expand below"); }); @@ -200,15 +203,37 @@ describe("chunk mode tools", () => { const result = await tool.execute("chunk-read-lines-in-path", { path: `${filePath}:L2-L4` }); const text = getText(result); - expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]"); + expect(text).toContain( + "[Notice: chunk view scoped to requested lines L2-L4; clipped chunks keep head/tail context and collapse non-overlapping children.]", + ); expect(text).toContain("server.ts·"); expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}#`); expect(text).toContain(`[<${HANDLE_ERROR_CHUNK_PATH}.var_total#`); expect(text).toContain("3|"); + expect(text).toContain("64|\t\treturn err.message + total;"); + expect(text).toContain("[truncated… sel=L5-L62 to expand]"); expect(text).not.toContain("to expand above"); expect(text).not.toContain("to expand below"); }); + it("keeps chunk head and tail context when a line range lands in the middle of a large chunk", async () => { + const filePath = path.join(tmpDir, "server.ts"); + await Bun.write(filePath, buildLargeTypescriptFixture()); + const tool = new ReadTool(createSession(tmpDir)); + + const result = await tool.execute("chunk-read-lines-middle", { path: filePath, sel: "L40-L42" }); + const text = getText(result); + + expect(text).toContain("2|\tprivate handleError(err: Error): string {"); + expect(text).toContain("40|\t\t\ttotal += 36;"); + expect(text).toContain("42|\t\t\ttotal += 38;"); + expect(text).toContain("63|\t\t\ttotal += 59;"); + expect(text).toContain("64|\t\treturn err.message + total;"); + expect(text).toContain("65|\t}"); + expect(text).toContain("[truncated… sel=L3-L39 to expand]"); + expect(text).toContain("[truncated… sel=L43-L62 to expand]"); + }); + it("ignores a chunk selector checksum suffix on read", async () => { const filePath = path.join(tmpDir, "server.ts"); const originalSource = buildLargeTypescriptFixture(); @@ -271,8 +296,8 @@ describe("chunk mode tools", () => { }); const text = getText(result); - expect(text).toContain(`server.ts:${HANDLE_ERROR_CHUNK_PATH}`); - expect(text).toContain(".ret>64|"); + expect(text).toContain("[ { }); const text = getText(result); - expect(text).toContain('> 1|const top = "match";'); + expect(text).toContain(' 1|const top = "match";'); + }); + + it("groups chunk-mode grep output by directory, file, and chunk", async () => { + const toolsDir = path.join(tmpDir, "src", "tools"); + const coreDir = path.join(tmpDir, "src", "core"); + await fs.mkdir(toolsDir, { recursive: true }); + await fs.mkdir(coreDir, { recursive: true }); + await Bun.write( + path.join(toolsDir, "grep.ts"), + ["function execute(): string {", ' return "needle";', "}", ""].join("\n"), + ); + await Bun.write( + path.join(coreDir, "server.ts"), + ["class Server {", " start(): string {", ' return "needle";', " }", "}", ""].join("\n"), + ); + const tool = new GrepTool(createSession(tmpDir)); + + const result = await tool.execute("chunk-grep-grouped", { + pattern: "needle", + path: tmpDir, + glob: "src/**/*.ts", + }); + const text = getText(result); + + expect(text).toContain("# src/tools"); + expect(text).toContain("## └─ grep.ts"); + expect(text).toContain(" [ { @@ -327,6 +384,7 @@ describe("chunk mode tools", () => { expect(updatedSource).toContain("normalized:"); expect(updatedSource).not.toContain("total +="); expect(editText).toContain("server.ts·"); + expect(editText).toContain("[ { } as never); const editText = getText(editResult); - expect(editText).toContain("[ {