feat(pi-natives/chunk): implemented chunk grep tree output for safe edit

- Added hierarchical chunk grep output grouped by directory, file, and chunk with aligned chunk anchors.
- Updated chunk rendering to show clipped head/tail context with explicit truncation and expand markers.
- Changed chunk edit and streaming validation to use chunkToolEditSchema-based checks for safe edit detection.
- Enhanced grep rendering to use structured ChunkedGrepMatch data with displayPath and chunk checksum metadata.
This commit is contained in:
can1357
2026-04-13 05:43:18 +02:00
parent 6de07594e5
commit e992ba6d4e
12 changed files with 658 additions and 463 deletions
+57 -70
View File
@@ -70,6 +70,12 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result<EditResult
let initial_parse_errors = state.tree.parse_errors;
let initial_chunk_paths: std::collections::HashSet<String> =
state.tree.chunks.iter().map(|c| c.path.clone()).collect();
let initial_chunk_checksums: std::collections::HashMap<String, String> = 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<EditResult
&diff_after,
params.anchor_style,
&touched_paths,
&initial_chunk_checksums,
normalize_indent,
)
} else {
@@ -1852,17 +1859,15 @@ fn render_changed_hunks(
after: &str,
anchor_style: Option<ChunkAnchorStyle>,
touched_paths: &[String],
before_checksums: &std::collections::HashMap<String, String>,
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<String, Vec<crate::chunk::render::InlineHunk>> = 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::<Vec<_>>()
.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.
+18 -13
View File
@@ -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
);
}
+364 -315
View File
@@ -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<u
}
pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> 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<String, Vec<InlineHunk>>,
changed_anchor_paths: HashSet<String>,
) -> String {
render_state_impl(state, params, inline_hunks, changed_anchor_paths, true, true)
}
fn render_state_impl(
state: &ChunkStateInner,
params: &RenderParams,
inline_hunks: HashMap<String, Vec<InlineHunk>>,
changed_anchor_paths: HashSet<String>,
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<LineSegment>, 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<LineSegment> {
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<u32> = 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::<Vec<_>>();
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::<Vec<_>>();
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<u32>,
focus: Option<HashMap<&'a str, ChunkFocusMode>>,
inline_hunks: HashMap<String, Vec<InlineHunk>>,
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<u32>,
focus: Option<HashMap<&'a str, ChunkFocusMode>>,
inline_hunks: HashMap<String, Vec<InlineHunk>>,
compact_meta: bool,
omit_anchor_line_counts: bool,
changed_anchor_paths: HashSet<String>,
}
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<String> = 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::<Vec<_>>(),
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, Vec<InlineHunk>>,
) -> 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::<Vec<_>>();
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<HashMap<&str, ChunkFocusMode>> = 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
}
+2 -2
View File
@@ -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 {
+5
View File
@@ -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: `[</ name#crc >]`.
/// Returns empty string for `None` style.
pub fn render_close(&self, indent: &str, name: &str, crc: &str) -> String {
+3 -1
View File
@@ -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
+21 -3
View File
@@ -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<string> {
}): Promise<ChunkedGrepMatch> {
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";
+3 -2
View File
@@ -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<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
return "sel" in edit;
return Type.Partial(chunkToolEditSchema).Check(edit);
}
function getStreamingEditContent(content: unknown): string {
@@ -71,52 +71,52 @@ Given this `read` output for `example.ts`:
| example.ts·34L·ts·#QBMH
|
| [<interface_Config#BWTR>]
1| interface Config {
1|interface Config {
| [<interface_Config.field_host#TTMN>]
2| host: string;
2| host: string;
| [<interface_Config.field_port#QSMH>]
3| port: number;
3| port: number;
| [<interface_Config.field_debug#JPRR>]
4| debug: boolean;
5| }
4| debug: boolean;
5|}
|
| [<class_Counter#HZHY>]
7| class Counter {
7|class Counter {
| [<class_Counter.field_value#QJBY>]
8| value: number = 0;
8| value: number = 0;
9|
| [<class_Counter.fn_increment#NQWY>]
10| increment(): void {
11| this.value += 1;
12| }
10| increment(): void {
11| this.value += 1;
12| }
13|
| [<class_Counter.fn_decrement#PMBP>]
14| decrement(): void {
15| this.value -= 1;
16| }
14| decrement(): void {
15| this.value -= 1;
16| }
17|
| [<class_Counter.fn_toString#ZQZP>]
18| toString(): string {
19| return `Counter(${this.value})`;
20| }
21| }
18| toString(): string {
19| return `Counter(${this.value})`;
20| }
21|}
|
| [<enum_Status#HYQJ>]
23| enum Status {
23|enum Status {
| [<enum_Status.variant_Active#PQNS>]
24| Active = "ACTIVE",
24| Active = "ACTIVE",
| [<enum_Status.variant_Paused#HHNM>]
25| Paused = "PAUSED",
25| Paused = "PAUSED",
| [<enum_Status.variant_Stopped#NHTY>]
26| Stopped = "STOPPED",
27| }
26| Stopped = "STOPPED",
27|}
|
| [<fn_createCounter#PQQY>]
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):
+72 -6
View File
@@ -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<typeof grepSchema, GrepToolDetails> {
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<typeof grepSchema, GrepToolDetails> {
});
}),
);
const rawOutput = annotatedLines.join("\n");
const chunkMatchesByFile = new Map<string, ChunkedGrepMatch[]>();
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<string, ChunkedGrepMatch[]>();
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<string, string[]>();
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 = {
@@ -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 () => {
@@ -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("[<class_Server.fn_handle.ret#");
expect(text).toContain("64| return err.message + total;");
expect(text).toContain("err.message");
});
@@ -292,7 +317,39 @@ describe("chunk mode tools", () => {
});
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(" [<fn_execut#");
expect(text).toContain(' 2| return "needle";');
expect(text).toContain("# src/core");
expect(text).toContain("## └─ server.ts");
expect(text).toContain(" [<class_Server.fn_start#");
expect(text).toContain(' 3| return "needle";');
});
it("replaces a chunk using a copied selector in path", async () => {
@@ -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("[<class_Server.fn_handle#");
expect(editText).toContain("@@ -3,62 +3,1 @@");
});
@@ -365,12 +423,16 @@ describe("chunk mode tools", () => {
} as never);
const editText = getText(editResult);
expect(editText).toContain("[<class_Server#");
expect(editText).toContain("1| class Server {");
expect(editText).toContain("[<class_Server.fn_handle#");
expect(editText).toContain('3| \t\tconsole.log("new");');
expect(editText).toContain("@@ -3,1 +3,1 @@");
expect(editText).toContain("[<class_Server.fn_other#");
expect(editText).toMatch(/^ {2}\|server\.ts·10L·typescript·#/m);
expect(editText).toMatch(/^\* \|\[<class_Server#/m);
expect(editText).toMatch(/^\* \|\s+\[<class_Server\.fn_handle#/m);
expect(editText).toMatch(/^ {2}\|\s+@@ -3,1 \+3,1 @@$/m);
expect(editText).toContain('console.log("old");');
expect(editText).toContain('console.log("new");');
expect(editText).not.toContain("1|class Server {");
expect(editText).not.toContain("crc updated");
expect(editText).not.toContain(" lns)");
expect(editText).not.toContain("[<class_Server.fn_other#");
});
it("replaces a whole method chunk when PI_CHUNK_AUTOINDENT=0", async () => {