feat(coding-agent): added multi-file edits in coding-agent with per-entry payloads
- Added multi-file edit payload support by requiring per-entry `edits` arrays across patch, hashline, replace, and chunk modes. - Changed edit schemas and validators to move `path` (and `sel`->`path`) and op data into each edit entry. - Changed edit execution flow to parse `file:selector` paths, run single-entry executors per file, and return grouped per-file results. - Updated chunk diff rendering and test expectations to emit consistent anchor gutters with spaced pipes and `^` head-line markers.
This commit is contained in:
@@ -355,6 +355,9 @@ fn classify_function_js<'t>(node: Node<'t>, source: &str) -> RawChunkCandidate<'
|
||||
source,
|
||||
),
|
||||
|
||||
// ── Expression statements (enable call-with-callback promotion) ──
|
||||
"expression_statement" => group_candidate(node, ChunkKind::Statements, source),
|
||||
|
||||
// ── Fallback ──
|
||||
_ => group_from_sanitized(node, source),
|
||||
}
|
||||
|
||||
@@ -1971,7 +1971,7 @@ fn render_changed_hunks(
|
||||
let anchor_label = deleted_chunk_anchor_label(deleted_chunk, style);
|
||||
let mut lines = Vec::with_capacity(hunk.lines.len() + 2);
|
||||
lines.push(crate::chunk::render::InlineHunkLine {
|
||||
text: style.render_without_counts(
|
||||
text: style.render(
|
||||
&anchor_indent,
|
||||
anchor_label.as_str(),
|
||||
deleted_chunk.checksum.as_str(),
|
||||
@@ -2762,7 +2762,7 @@ mod tests {
|
||||
let response = &result.response_text;
|
||||
assert!(response.contains("var_c"), "touched chunk should appear: {response}");
|
||||
assert!(
|
||||
response.contains("* |[<var_c#"),
|
||||
response.contains("* |[<var_c#"),
|
||||
"changed chunk should be marked in the gutter: {response}"
|
||||
);
|
||||
assert!(
|
||||
@@ -4678,7 +4678,7 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>>
|
||||
result.response_text
|
||||
);
|
||||
assert!(
|
||||
result.response_text.contains("-|")
|
||||
result.response_text.contains("- |")
|
||||
&& result.response_text.contains("[<enum_Level.vrnt_Debug#"),
|
||||
"deleted variant should keep its chunk anchor with a deletion marker: {}",
|
||||
result.response_text
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn format_anchor(
|
||||
) -> String {
|
||||
style
|
||||
.with_omit_checksum(omit_checksum.unwrap_or(false))
|
||||
.render("", name.as_str(), checksum.as_str(), 0, 0)
|
||||
.render("", name.as_str(), checksum.as_str())
|
||||
}
|
||||
|
||||
// ── Core build logic ─────────────────────────────────────────────────────
|
||||
@@ -1398,6 +1398,45 @@ function main(): void {{
|
||||
assert!(stmts_chunk.leaf, "stmts with no callback should be a leaf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_call_with_callback_has_body_region() {
|
||||
// Nested test()/it() calls inside describe() should be promoted with
|
||||
// prologue/epilogue set so that `~` targets the callback body, not the
|
||||
// entire chunk.
|
||||
let source = "\
|
||||
describe(\"suite\", () => {
|
||||
\ttest(\"my test\", () => {
|
||||
\t\tconst x = 1;
|
||||
\t\tconst y = 2;
|
||||
\t\tconst z = 3;
|
||||
\t\texpect(x + y).toBe(z);
|
||||
\t});
|
||||
|
||||
\ttest(\"other test\", () => {
|
||||
\t\tconst a = 10;
|
||||
\t\tconst b = 20;
|
||||
\t\tconst c = 30;
|
||||
\t\texpect(a + b).toBe(c);
|
||||
\t});
|
||||
});
|
||||
";
|
||||
let tree = build_chunk_tree(source, "typescript").expect("tree should build");
|
||||
|
||||
let test_chunk = tree
|
||||
.chunks
|
||||
.iter()
|
||||
.find(|c| c.path.starts_with("expr_descri.expr_test"))
|
||||
.expect("test() should be a promoted named chunk under describe");
|
||||
assert!(
|
||||
test_chunk.prologue_end_byte.is_some(),
|
||||
"test() chunk should have prologue_end_byte for ~ region support"
|
||||
);
|
||||
assert!(
|
||||
test_chunk.epilogue_start_byte.is_some(),
|
||||
"test() chunk should have epilogue_start_byte for ~ region support"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsx_return_with_map_callback_exposes_nested_chunks() {
|
||||
// A React component body that returns `items.map(item =>
|
||||
@@ -3050,12 +3089,12 @@ end
|
||||
// The output should keep the chunk head and tail context and collapse the
|
||||
// omitted middle ranges with generic expansion markers.
|
||||
assert!(
|
||||
result.text.contains("1|function longFunc() {"),
|
||||
result.text.contains("1^|function longFunc() {"),
|
||||
"should keep the chunk signature when the visible range clips the head: {}",
|
||||
result.text
|
||||
);
|
||||
assert!(
|
||||
result.text.contains("9|let h = 8;"),
|
||||
result.text.contains("9 |let h = 8;"),
|
||||
"should keep tail context when the visible range clips the body: {}",
|
||||
result.text
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ 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)
|
||||
render_state_impl(state, params, HashMap::new(), HashSet::new(), false)
|
||||
}
|
||||
|
||||
pub fn render_state_with_hunks(
|
||||
@@ -107,7 +107,7 @@ pub fn render_state_with_hunks(
|
||||
inline_hunks: HashMap<String, Vec<InlineHunk>>,
|
||||
changed_anchor_paths: HashSet<String>,
|
||||
) -> String {
|
||||
render_state_impl(state, params, inline_hunks, changed_anchor_paths, true, true)
|
||||
render_state_impl(state, params, inline_hunks, changed_anchor_paths, true)
|
||||
}
|
||||
|
||||
fn render_state_impl(
|
||||
@@ -116,7 +116,6 @@ fn render_state_impl(
|
||||
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);
|
||||
@@ -201,7 +200,6 @@ fn render_state_impl(
|
||||
focus,
|
||||
inline_hunks,
|
||||
compact_meta,
|
||||
omit_anchor_line_counts,
|
||||
changed_anchor_paths,
|
||||
};
|
||||
|
||||
@@ -843,28 +841,27 @@ 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>>,
|
||||
compact_meta: bool,
|
||||
omit_anchor_line_counts: bool,
|
||||
changed_anchor_paths: HashSet<String>,
|
||||
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,
|
||||
changed_anchor_paths: HashSet<String>,
|
||||
}
|
||||
|
||||
fn push_line(out: &mut String, line: String) {
|
||||
@@ -878,15 +875,15 @@ fn push_blank_meta(ctx: &mut RenderCtx<'_>) {
|
||||
if ctx.last_was_blank_meta {
|
||||
return;
|
||||
}
|
||||
push_line(&mut ctx.out, format!("{}|", " ".repeat(ctx.num_width)));
|
||||
push_line(&mut ctx.out, format!("{} |", " ".repeat(ctx.num_width)));
|
||||
ctx.last_was_blank_meta = true;
|
||||
}
|
||||
|
||||
fn push_meta_marked(ctx: &mut RenderCtx<'_>, body: String, marker: Option<char>) {
|
||||
ctx.last_was_blank_meta = false;
|
||||
let gutter = match marker {
|
||||
Some(marker) => format!("{marker}{}", " ".repeat(ctx.num_width.saturating_sub(1))),
|
||||
None => " ".repeat(ctx.num_width),
|
||||
Some(marker) => format!("{marker}{}", " ".repeat(ctx.num_width)),
|
||||
None => " ".repeat(ctx.num_width + 1),
|
||||
};
|
||||
let separator = if ctx.compact_meta { "|" } else { "| " };
|
||||
push_line(&mut ctx.out, format!("{gutter}{separator}{body}"));
|
||||
@@ -896,11 +893,17 @@ fn push_meta(ctx: &mut RenderCtx<'_>, body: String) {
|
||||
push_meta_marked(ctx, body, None);
|
||||
}
|
||||
|
||||
fn push_code(ctx: &mut RenderCtx<'_>, abs_line: u32, source_text: &str) {
|
||||
fn line_is_in_head(source: &str, chunk: &ChunkNode, abs_line: u32) -> bool {
|
||||
let (head_lines, body_lines) = chunk_head_body_lines(source, chunk);
|
||||
body_lines > 0 && abs_line >= chunk.start_line && abs_line < chunk.start_line + head_lines
|
||||
}
|
||||
|
||||
fn push_code(ctx: &mut RenderCtx<'_>, abs_line: u32, source_text: &str, head: bool) {
|
||||
ctx.last_was_blank_meta = false;
|
||||
let marker = if head { '^' } else { ' ' };
|
||||
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, ' '), marker, source_text),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -917,7 +920,7 @@ impl PadStart for String {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_line_gap(ctx: &mut RenderCtx<'_>, from: u32, to: u32) {
|
||||
fn emit_line_gap(ctx: &mut RenderCtx<'_>, from: u32, to: u32, chunk: &ChunkNode) {
|
||||
for line in from..=to {
|
||||
if !line_in_file_scope(line, ctx.visible_range) {
|
||||
continue;
|
||||
@@ -933,7 +936,8 @@ fn emit_line_gap(ctx: &mut RenderCtx<'_>, from: u32, to: u32) {
|
||||
.map_or(String::new(), |text| {
|
||||
normalize_rendered_line(text, normalize, ctx.tab_replacement)
|
||||
});
|
||||
push_code(ctx, line, &text);
|
||||
let head = line_is_in_head(ctx.source, chunk, line);
|
||||
push_code(ctx, line, &text, head);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,7 +969,8 @@ fn emit_explicit_gap_lines(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, from: u32
|
||||
.map_or(String::new(), |source_text| {
|
||||
normalize_rendered_line(source_text, normalize, ctx.tab_replacement)
|
||||
});
|
||||
push_code(ctx, line, &text);
|
||||
let head = line_is_in_head(ctx.source, chunk, line);
|
||||
push_code(ctx, line, &text, head);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,7 +1059,10 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, span: VisibleSpan)
|
||||
ctx.preview_tail_lines,
|
||||
) {
|
||||
match entry {
|
||||
LeafEntry::Line { abs_line, text } => push_code(ctx, abs_line, &text),
|
||||
LeafEntry::Line { abs_line, text } => {
|
||||
let head = line_is_in_head(ctx.source, chunk, abs_line);
|
||||
push_code(ctx, abs_line, &text, head);
|
||||
},
|
||||
LeafEntry::Ellipsis { start_abs, end_abs, .. } => {
|
||||
push_truncation_marker(ctx, chunk, start_abs, end_abs);
|
||||
},
|
||||
@@ -1065,20 +1073,9 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, chunk: &ChunkNode, span: VisibleSpan)
|
||||
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,
|
||||
)
|
||||
}
|
||||
style.render(&anchor_indent, anchor_label.as_str(), chunk.checksum.as_str())
|
||||
}
|
||||
|
||||
fn emit_inline_hunks_for(ctx: &mut RenderCtx<'_>, chunk_path: &str) {
|
||||
@@ -1183,7 +1180,7 @@ fn emit_chunk_subtree(
|
||||
if line_in_file_scope(line, ctx.visible_range)
|
||||
&& should_render_gap_line(ctx.tree, chunk, ctx.lookup, line)
|
||||
{
|
||||
emit_line_gap(ctx, line, line);
|
||||
emit_line_gap(ctx, line, line, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1194,7 +1191,7 @@ fn emit_chunk_subtree(
|
||||
cursor = cursor.max(child.end_line.saturating_add(1));
|
||||
}
|
||||
if cursor <= span.end && !is_container {
|
||||
emit_line_gap(ctx, cursor, span.end);
|
||||
emit_line_gap(ctx, cursor, span.end, chunk);
|
||||
}
|
||||
emit_container_clip_below(ctx, chunk, &span, &children);
|
||||
if !chunk.path.is_empty() {
|
||||
|
||||
@@ -238,31 +238,8 @@ impl ChunkAnchorStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an opening anchor tag with head/body line counts:
|
||||
/// `[< name#crc >] (H+B lns)` for containers, `[< name#crc >] (N lns)` for
|
||||
/// leaves. Omitted when total lines <= 1. Returns empty string for `None`
|
||||
/// style.
|
||||
pub fn render(
|
||||
&self,
|
||||
indent: &str,
|
||||
name: &str,
|
||||
crc: &str,
|
||||
head_lines: u32,
|
||||
body_lines: u32,
|
||||
) -> String {
|
||||
let total = head_lines + body_lines;
|
||||
let suffix = if total <= 1 {
|
||||
String::new()
|
||||
} else if body_lines == 0 {
|
||||
format!(" ({total} lns)")
|
||||
} else {
|
||||
format!(" ({head_lines}+{body_lines} lns)")
|
||||
};
|
||||
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 {
|
||||
/// Render an opening anchor tag
|
||||
pub fn render(&self, indent: &str, name: &str, crc: &str) -> String {
|
||||
self.render_i(("[<", ">]"), indent, name, crc, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed the `edit` schemas for patch, replace, hashline, and chunk modes from top-level request fields to `edits` array entries, requiring path/mode details on each edit and breaking callers that send legacy top-level `path`, `old_text`, `new_text`, `op`, `move`, or `delete` payloads
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for multi-file `edit` calls across replace, patch, hashline, and chunk modes by grouping `edits` entries by file path and returning combined per-file results
|
||||
- Added per-edit `path` support in chunk entries so each operation can target explicit files when submitting mixed edits in a single request
|
||||
- Added support for `computeHashlineDiff` to accept hashline edits with `loc` and `content` payloads without requiring pre-resolved `op` fields
|
||||
- Added `/rename <title>` slash command to set an explicit session name, updating the session header and terminal tab title ([#658](https://github.com/can1357/oh-my-pi/issues/658))
|
||||
- Added `session_name` status line segment: displays the session name in the status bar right side with a stable hash-derived accent color unique to each name; shown in all presets when a name is set
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed chunk edit payloads to encode selectors as `path: "file:selector"` and updated chunk tool guidance and examples to match
|
||||
- Updated `edit` call/result rendering to show per-file diff sections and append a `(+N more)` hint when edits target multiple files
|
||||
- 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 pre-execution edit preview routing so replace/patch/hashline mode diffs are computed from the new structured edit entries
|
||||
- Adjusted chunk/hashline/prompt guidance and validation to align with the refactored per-entry schema
|
||||
- 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
|
||||
|
||||
@@ -15,16 +15,36 @@ import type { ToolSession } from "../tools";
|
||||
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
import {
|
||||
type ChunkParams,
|
||||
type ChunkToolEdit,
|
||||
chunkEditParamsSchema,
|
||||
executeChunkMode,
|
||||
executeChunkSingle,
|
||||
isChunkParams,
|
||||
parseChunkEditPath,
|
||||
resolveAnchorStyle,
|
||||
resolveChunkAutoIndent,
|
||||
} from "./modes/chunk";
|
||||
import { executeHashlineMode, type HashlineParams, hashlineEditParamsSchema, isHashlineParams } from "./modes/hashline";
|
||||
import { executePatchMode, isPatchParams, type PatchParams, patchEditSchema } from "./modes/patch";
|
||||
import { executeReplaceMode, isReplaceParams, type ReplaceParams, replaceEditSchema } from "./modes/replace";
|
||||
import { type EditToolDetails, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
||||
import {
|
||||
executeHashlineSingle,
|
||||
type HashlineParams,
|
||||
type HashlineToolEdit,
|
||||
hashlineEditParamsSchema,
|
||||
isHashlineParams,
|
||||
} from "./modes/hashline";
|
||||
import {
|
||||
executePatchSingle,
|
||||
isPatchParams,
|
||||
type PatchEditEntry,
|
||||
type PatchParams,
|
||||
patchEditSchema,
|
||||
} from "./modes/patch";
|
||||
import {
|
||||
executeReplaceSingle,
|
||||
isReplaceParams,
|
||||
type ReplaceEditEntry,
|
||||
type ReplaceParams,
|
||||
replaceEditSchema,
|
||||
} from "./modes/replace";
|
||||
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
||||
|
||||
export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
||||
export * from "./diff";
|
||||
@@ -44,18 +64,17 @@ type TInput =
|
||||
|
||||
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams;
|
||||
|
||||
type ModeExecutionArgs = {
|
||||
params: EditParams;
|
||||
signal: AbortSignal | undefined;
|
||||
batchRequest: LspBatchRequest | undefined;
|
||||
};
|
||||
|
||||
type EditModeDefinition = {
|
||||
description: (session: ToolSession) => string;
|
||||
parameters: TInput;
|
||||
invalidParamsMessage: string;
|
||||
validate: (params: EditParams) => boolean;
|
||||
execute: (tool: EditTool, args: ModeExecutionArgs) => Promise<AgentToolResult<EditToolDetails, TInput>>;
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
) => Promise<AgentToolResult<EditToolDetails, TInput>>;
|
||||
};
|
||||
|
||||
function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
|
||||
@@ -106,6 +125,78 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
|
||||
return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
|
||||
}
|
||||
|
||||
/** Group items by a key, preserving insertion order. */
|
||||
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
|
||||
const map = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const k = key(item);
|
||||
let arr = map.get(k);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
map.set(k, arr);
|
||||
}
|
||||
arr.push(item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Run single-file executors for each file group and aggregate results. */
|
||||
async function executePerFile(
|
||||
fileEntries: {
|
||||
path: string;
|
||||
run: (batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails, any>>;
|
||||
}[],
|
||||
outerBatchRequest: LspBatchRequest | undefined,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
||||
if (fileEntries.length === 1) {
|
||||
// Single file — just run directly, no wrapping
|
||||
return fileEntries[0].run(outerBatchRequest);
|
||||
}
|
||||
|
||||
const perFileResults: EditToolPerFileResult[] = [];
|
||||
const contentTexts: string[] = [];
|
||||
|
||||
for (let i = 0; i < fileEntries.length; i++) {
|
||||
const { path, run } = fileEntries[i];
|
||||
const isLast = i === fileEntries.length - 1;
|
||||
const batchRequest: LspBatchRequest | undefined = outerBatchRequest
|
||||
? { id: outerBatchRequest.id, flush: isLast && outerBatchRequest.flush }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const result = await run(batchRequest);
|
||||
const details = result.details;
|
||||
perFileResults.push({
|
||||
path,
|
||||
diff: details?.diff ?? "",
|
||||
firstChangedLine: details?.firstChangedLine,
|
||||
diagnostics: details?.diagnostics,
|
||||
op: details?.op,
|
||||
move: details?.move,
|
||||
meta: details?.meta,
|
||||
});
|
||||
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
||||
if (text) contentTexts.push(text);
|
||||
} catch (err) {
|
||||
const errorText = err instanceof Error ? err.message : String(err);
|
||||
perFileResults.push({ path, diff: "", isError: true, errorText });
|
||||
contentTexts.push(`Error editing ${path}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: contentTexts.join("\n") }],
|
||||
details: {
|
||||
diff: perFileResults
|
||||
.map(r => r.diff)
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
|
||||
perFileResults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class EditTool implements AgentTool<TInput> {
|
||||
readonly name = "edit";
|
||||
readonly label = "Edit";
|
||||
@@ -145,34 +236,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
return this.#getModeDefinition().parameters;
|
||||
}
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: ReplaceParams,
|
||||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>>;
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: PatchParams,
|
||||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>>;
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: HashlineParams,
|
||||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>>;
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: ChunkParams,
|
||||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>>;
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: EditParams,
|
||||
@@ -185,11 +248,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
throw new Error(modeDefinition.invalidParamsMessage);
|
||||
}
|
||||
|
||||
return modeDefinition.execute(this, {
|
||||
params,
|
||||
signal,
|
||||
batchRequest: getLspBatchRequest(context?.toolCall),
|
||||
});
|
||||
return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall));
|
||||
}
|
||||
|
||||
#getModeDefinition(): EditModeDefinition {
|
||||
@@ -203,15 +262,28 @@ export class EditTool implements AgentTool<TInput> {
|
||||
parameters: chunkEditParamsSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for chunk mode.",
|
||||
validate: isChunkParams,
|
||||
async execute(tool: EditTool, args: ModeExecutionArgs) {
|
||||
return executeChunkMode({
|
||||
session: tool.session,
|
||||
params: args.params as ChunkParams,
|
||||
signal: args.signal,
|
||||
batchRequest: args.batchRequest,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path),
|
||||
});
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
) => {
|
||||
const { edits } = params as ChunkParams;
|
||||
const byFile = groupBy(edits, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
|
||||
const entries = [...byFile.entries()].map(([filePath, fileEdits]) => ({
|
||||
path: filePath,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executeChunkSingle({
|
||||
session: tool.session,
|
||||
path: filePath,
|
||||
edits: fileEdits,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
||||
}),
|
||||
}));
|
||||
return executePerFile(entries, batchRequest);
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
@@ -219,17 +291,28 @@ export class EditTool implements AgentTool<TInput> {
|
||||
parameters: patchEditSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for patch mode.",
|
||||
validate: isPatchParams,
|
||||
async execute(tool: EditTool, args: ModeExecutionArgs) {
|
||||
return executePatchMode({
|
||||
session: tool.session,
|
||||
params: args.params as PatchParams,
|
||||
signal: args.signal,
|
||||
batchRequest: args.batchRequest,
|
||||
allowFuzzy: tool.#allowFuzzy,
|
||||
fuzzyThreshold: tool.#fuzzyThreshold,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path),
|
||||
});
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
) => {
|
||||
const { edits } = params as PatchParams;
|
||||
const entries = edits.map((entry: PatchEditEntry) => ({
|
||||
path: entry.path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executePatchSingle({
|
||||
session: tool.session,
|
||||
params: entry,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
allowFuzzy: tool.#allowFuzzy,
|
||||
fuzzyThreshold: tool.#fuzzyThreshold,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
||||
}),
|
||||
}));
|
||||
return executePerFile(entries, batchRequest);
|
||||
},
|
||||
},
|
||||
hashline: {
|
||||
@@ -237,15 +320,28 @@ export class EditTool implements AgentTool<TInput> {
|
||||
parameters: hashlineEditParamsSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for hashline mode.",
|
||||
validate: isHashlineParams,
|
||||
async execute(tool: EditTool, args: ModeExecutionArgs) {
|
||||
return executeHashlineMode({
|
||||
session: tool.session,
|
||||
params: args.params as HashlineParams,
|
||||
signal: args.signal,
|
||||
batchRequest: args.batchRequest,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path),
|
||||
});
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
) => {
|
||||
const { edits } = params as HashlineParams;
|
||||
const byFile = groupBy(edits, (e: HashlineToolEdit) => e.path);
|
||||
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
|
||||
path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executeHashlineSingle({
|
||||
session: tool.session,
|
||||
path,
|
||||
edits: fileEdits,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
||||
}),
|
||||
}));
|
||||
return executePerFile(entries, batchRequest);
|
||||
},
|
||||
},
|
||||
replace: {
|
||||
@@ -253,17 +349,28 @@ export class EditTool implements AgentTool<TInput> {
|
||||
parameters: replaceEditSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for replace mode.",
|
||||
validate: isReplaceParams,
|
||||
async execute(tool: EditTool, args: ModeExecutionArgs) {
|
||||
return executeReplaceMode({
|
||||
session: tool.session,
|
||||
params: args.params as ReplaceParams,
|
||||
signal: args.signal,
|
||||
batchRequest: args.batchRequest,
|
||||
allowFuzzy: tool.#allowFuzzy,
|
||||
fuzzyThreshold: tool.#fuzzyThreshold,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path),
|
||||
});
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
) => {
|
||||
const { edits } = params as ReplaceParams;
|
||||
const entries = edits.map((entry: ReplaceEditEntry) => ({
|
||||
path: entry.path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executeReplaceSingle({
|
||||
session: tool.session,
|
||||
params: entry,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
allowFuzzy: tool.#allowFuzzy,
|
||||
fuzzyThreshold: tool.#fuzzyThreshold,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
||||
}),
|
||||
}));
|
||||
return executePerFile(entries, batchRequest);
|
||||
},
|
||||
},
|
||||
}[this.mode];
|
||||
|
||||
@@ -197,6 +197,17 @@ export function parseChunkSelector(selector: string | undefined): { selector?: s
|
||||
return { selector };
|
||||
}
|
||||
|
||||
/** Split a combined `file:selector` path into file path and chunk selector. */
|
||||
export function parseChunkEditPath(editPath: string | undefined): { filePath: string; selector?: string } {
|
||||
if (!editPath) return { filePath: "" };
|
||||
const colonIndex = chunkReadPathSeparatorIndex(editPath);
|
||||
if (colonIndex === -1) {
|
||||
return { filePath: editPath };
|
||||
}
|
||||
const sel = editPath.slice(colonIndex + 1) || undefined;
|
||||
return { filePath: editPath.slice(0, colonIndex), selector: sel };
|
||||
}
|
||||
|
||||
export function parseChunkReadPath(readPath: string): ParsedChunkReadPath {
|
||||
const colonIndex = chunkReadPathSeparatorIndex(readPath);
|
||||
if (colonIndex === -1) {
|
||||
@@ -464,9 +475,9 @@ const CHUNK_OP_VALUES = ["replace", "after", "before", "prepend", "append"] as c
|
||||
|
||||
export const chunkToolEditSchema = Type.Object({
|
||||
op: StringEnum(CHUNK_OP_VALUES),
|
||||
sel: Type.String({
|
||||
path: Type.String({
|
||||
description:
|
||||
"Chunk selector. Use 'path~' or 'path^' for insertions, 'path#CRC~' or 'path#CRC^' for replace, or omit the suffix to target the full chunk.",
|
||||
"File path with chunk selector after colon. Format: 'file:sel~' or 'file:sel^' for insertions, 'file:sel#CRC~' or 'file:sel#CRC^' for replace. Omit suffix to target the full chunk. Examples: 'src/app.ts:fn_foo#ABCD~', 'src/app.ts:class_Bar'.",
|
||||
}),
|
||||
content: Type.String({
|
||||
description:
|
||||
@@ -475,7 +486,6 @@ export const chunkToolEditSchema = Type.Object({
|
||||
});
|
||||
export const chunkEditParamsSchema = Type.Object(
|
||||
{
|
||||
path: Type.String({ description: "File path" }),
|
||||
edits: Type.Array(chunkToolEditSchema, {
|
||||
description: "Chunk edits",
|
||||
minItems: 1,
|
||||
@@ -487,9 +497,10 @@ export const chunkEditParamsSchema = Type.Object(
|
||||
export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
|
||||
export type ChunkParams = Static<typeof chunkEditParamsSchema>;
|
||||
|
||||
interface ExecuteChunkModeOptions {
|
||||
export interface ExecuteChunkSingleOptions {
|
||||
session: ToolSession;
|
||||
params: ChunkParams;
|
||||
path: string;
|
||||
edits: ChunkToolEdit[];
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
writethrough: WritethroughCallback;
|
||||
@@ -505,12 +516,17 @@ export function isChunkParams(params: unknown): params is ChunkParams {
|
||||
params.edits.length > 0 &&
|
||||
typeof params.edits[0] === "object" &&
|
||||
params.edits[0] !== null &&
|
||||
"sel" in params.edits[0]
|
||||
"op" in params.edits[0] &&
|
||||
"content" in params.edits[0] &&
|
||||
!("loc" in params.edits[0])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): ChunkEditOperation[] {
|
||||
return edits as ChunkEditOperation[];
|
||||
return edits.map(edit => {
|
||||
const { selector } = parseChunkEditPath(edit.path);
|
||||
return { op: edit.op, sel: selector, content: edit.content };
|
||||
});
|
||||
}
|
||||
|
||||
async function writeChunkResult(params: {
|
||||
@@ -562,11 +578,10 @@ async function writeChunkResult(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeChunkMode(
|
||||
options: ExecuteChunkModeOptions,
|
||||
export async function executeChunkSingle(
|
||||
options: ExecuteChunkSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
|
||||
const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
||||
const { path, edits } = params;
|
||||
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
||||
const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
|
||||
session,
|
||||
path,
|
||||
|
||||
@@ -128,18 +128,18 @@ const locSchema = Type.Union(
|
||||
|
||||
export const hashlineEditSchema = Type.Object(
|
||||
{
|
||||
loc: locSchema,
|
||||
content: linesSchema,
|
||||
path: Type.String({ description: "File path" }),
|
||||
loc: Type.Optional(locSchema),
|
||||
content: Type.Optional(linesSchema),
|
||||
delete: Type.Optional(Type.Boolean({ description: "Delete the file" })),
|
||||
move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const hashlineEditParamsSchema = Type.Object(
|
||||
{
|
||||
path: Type.String({ description: "path" }),
|
||||
edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }),
|
||||
delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })),
|
||||
move: Type.Optional(Type.String({ description: "If set, move $path to $move" })),
|
||||
edits: Type.Array(hashlineEditSchema, { description: "edits" }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -147,9 +147,10 @@ export const hashlineEditParamsSchema = Type.Object(
|
||||
export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
|
||||
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
||||
|
||||
interface ExecuteHashlineModeOptions {
|
||||
export interface ExecuteHashlineSingleOptions {
|
||||
session: ToolSession;
|
||||
params: HashlineParams;
|
||||
path: string;
|
||||
edits: HashlineToolEdit[];
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
writethrough: WritethroughCallback;
|
||||
@@ -166,14 +167,11 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
|
||||
}
|
||||
|
||||
export function isHashlineParams(params: unknown): params is HashlineParams {
|
||||
return (
|
||||
typeof params === "object" &&
|
||||
params !== null &&
|
||||
"edits" in params &&
|
||||
Array.isArray(params.edits) &&
|
||||
(params.edits.length === 0 ||
|
||||
(typeof params.edits[0] === "object" && params.edits[0] !== null && "loc" in params.edits[0]))
|
||||
);
|
||||
if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits)) return false;
|
||||
if (params.edits.length === 0) return true;
|
||||
const first = params.edits[0];
|
||||
if (typeof first !== "object" || first === null) return false;
|
||||
return "loc" in first || "delete" in first || "move" in first;
|
||||
}
|
||||
|
||||
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
||||
@@ -1200,15 +1198,20 @@ async function readHashlineFileText(file: BunFile, path: string): Promise<string
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeHashlineMode(
|
||||
options: ExecuteHashlineModeOptions,
|
||||
export async function executeHashlineSingle(
|
||||
options: ExecuteHashlineSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
||||
const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
||||
const { path, edits, delete: deleteFile, move } = params;
|
||||
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
||||
|
||||
// Extract file-level ops from edits
|
||||
const deleteFile = edits.some(e => e.delete);
|
||||
const move = edits.find(e => e.move)?.move;
|
||||
// Filter to content edits only (those with loc)
|
||||
const contentEdits = edits.filter(e => e.loc != null);
|
||||
|
||||
enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
|
||||
|
||||
if (path.endsWith(".ipynb") && edits?.length > 0) {
|
||||
if (path.endsWith(".ipynb") && contentEdits.length > 0) {
|
||||
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
||||
}
|
||||
|
||||
@@ -1220,7 +1223,7 @@ export async function executeHashlineMode(
|
||||
|
||||
const sourceFile = Bun.file(absolutePath);
|
||||
const sourceExists = await sourceFile.exists();
|
||||
const isMoveOnly = Boolean(resolvedMove) && edits.length === 0;
|
||||
const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0;
|
||||
|
||||
if (deleteFile) {
|
||||
if (sourceExists) {
|
||||
@@ -1260,7 +1263,7 @@ export async function executeHashlineMode(
|
||||
|
||||
if (!sourceExists) {
|
||||
const lines: string[] = [];
|
||||
for (const edit of edits) {
|
||||
for (const edit of contentEdits) {
|
||||
if (edit.loc === "append") {
|
||||
lines.push(...hashlineParseText(edit.content));
|
||||
} else if (edit.loc === "prepend") {
|
||||
@@ -1282,7 +1285,7 @@ export async function executeHashlineMode(
|
||||
};
|
||||
}
|
||||
|
||||
const anchorEdits = resolveEditAnchors(edits);
|
||||
const anchorEdits = resolveEditAnchors(contentEdits);
|
||||
const rawContent = await sourceFile.text();
|
||||
assertEditableFileContent(rawContent, path);
|
||||
|
||||
|
||||
@@ -1576,7 +1576,7 @@ export async function computePatchDiff(
|
||||
}
|
||||
}
|
||||
|
||||
export const patchEditSchema = Type.Object({
|
||||
export const patchEditEntrySchema = Type.Object({
|
||||
path: Type.String({ description: "File path" }),
|
||||
op: Type.Optional(
|
||||
StringEnum(["create", "delete", "update"], {
|
||||
@@ -1587,11 +1587,16 @@ export const patchEditSchema = Type.Object({
|
||||
diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
|
||||
});
|
||||
|
||||
export const patchEditSchema = Type.Object({
|
||||
edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
|
||||
});
|
||||
|
||||
export type PatchEditEntry = Static<typeof patchEditEntrySchema>;
|
||||
export type PatchParams = Static<typeof patchEditSchema>;
|
||||
|
||||
interface ExecutePatchModeOptions {
|
||||
export interface ExecutePatchSingleOptions {
|
||||
session: ToolSession;
|
||||
params: PatchParams;
|
||||
params: PatchEditEntry;
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
allowFuzzy: boolean;
|
||||
@@ -1601,10 +1606,11 @@ interface ExecutePatchModeOptions {
|
||||
}
|
||||
|
||||
export function isPatchParams(params: unknown): params is PatchParams {
|
||||
if (typeof params !== "object" || params === null || !("path" in params)) {
|
||||
return false;
|
||||
}
|
||||
return !("old_text" in params) && !("new_text" in params) && !("edits" in params);
|
||||
if (typeof params !== "object" || params === null) return false;
|
||||
if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
|
||||
const first = (params as any).edits[0];
|
||||
if (!first || typeof first !== "object") return false;
|
||||
return "path" in first && !("old_text" in first) && !("new_text" in first);
|
||||
}
|
||||
|
||||
class LspFileSystem implements FileSystem {
|
||||
@@ -1690,9 +1696,9 @@ function mergeDiagnosticsWithWarnings(
|
||||
};
|
||||
}
|
||||
|
||||
export async function executePatchMode(
|
||||
options: ExecutePatchModeOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof patchEditSchema>> {
|
||||
export async function executePatchSingle(
|
||||
options: ExecutePatchSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof patchEditEntrySchema>> {
|
||||
const {
|
||||
session,
|
||||
params,
|
||||
|
||||
@@ -987,18 +987,23 @@ export function findContextLine(
|
||||
return { index: undefined, confidence: bestScore };
|
||||
}
|
||||
|
||||
export const replaceEditSchema = Type.Object({
|
||||
export const replaceEditEntrySchema = Type.Object({
|
||||
path: Type.String({ description: "File path (relative or absolute)" }),
|
||||
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
|
||||
new_text: Type.String({ description: "Replacement text" }),
|
||||
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
|
||||
});
|
||||
|
||||
export const replaceEditSchema = Type.Object({
|
||||
edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
|
||||
});
|
||||
|
||||
export type ReplaceEditEntry = Static<typeof replaceEditEntrySchema>;
|
||||
export type ReplaceParams = Static<typeof replaceEditSchema>;
|
||||
|
||||
interface ExecuteReplaceModeOptions {
|
||||
export interface ExecuteReplaceSingleOptions {
|
||||
session: ToolSession;
|
||||
params: ReplaceParams;
|
||||
params: ReplaceEditEntry;
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
allowFuzzy: boolean;
|
||||
@@ -1008,12 +1013,15 @@ interface ExecuteReplaceModeOptions {
|
||||
}
|
||||
|
||||
export function isReplaceParams(params: unknown): params is ReplaceParams {
|
||||
return typeof params === "object" && params !== null && "old_text" in params && "new_text" in params;
|
||||
if (typeof params !== "object" || params === null) return false;
|
||||
if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
|
||||
const first = (params as any).edits[0];
|
||||
return first && typeof first === "object" && "old_text" in first && "new_text" in first;
|
||||
}
|
||||
|
||||
export async function executeReplaceMode(
|
||||
options: ExecuteReplaceModeOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof replaceEditSchema>> {
|
||||
export async function executeReplaceSingle(
|
||||
options: ExecuteReplaceSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
|
||||
const {
|
||||
session,
|
||||
params,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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";
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from "../tools/render-utils";
|
||||
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
||||
import type { DiffError, DiffResult } from "./diff";
|
||||
import { type ChunkToolEdit, chunkToolEditSchema } from "./modes/chunk";
|
||||
import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
|
||||
import type { HashlineToolEdit } from "./modes/hashline";
|
||||
import type { Operation } from "./modes/patch";
|
||||
|
||||
@@ -57,6 +56,18 @@ export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBa
|
||||
// Tool Details Types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface EditToolPerFileResult {
|
||||
path: string;
|
||||
diff: string;
|
||||
firstChangedLine?: number;
|
||||
diagnostics?: FileDiagnosticsResult;
|
||||
op?: Operation;
|
||||
move?: string;
|
||||
isError?: boolean;
|
||||
errorText?: string;
|
||||
meta?: OutputMeta;
|
||||
}
|
||||
|
||||
export interface EditToolDetails {
|
||||
/** Unified diff of the changes made */
|
||||
diff: string;
|
||||
@@ -70,6 +81,8 @@ export interface EditToolDetails {
|
||||
move?: string;
|
||||
/** Structured output metadata */
|
||||
meta?: OutputMeta;
|
||||
/** Per-file results (multi-file edits) */
|
||||
perFileResults?: EditToolPerFileResult[];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -183,7 +196,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
|
||||
}
|
||||
|
||||
function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
|
||||
return Type.Partial(chunkToolEditSchema).Check(edit);
|
||||
return typeof edit === "object" && edit !== null && "op" in edit && !("loc" in edit);
|
||||
}
|
||||
|
||||
function getStreamingEditContent(content: unknown): string {
|
||||
@@ -227,7 +240,7 @@ function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStream
|
||||
}
|
||||
|
||||
const contentLines = getStreamingEditContent(edit.content);
|
||||
const target = edit.sel ?? "?";
|
||||
const target = edit.path ? (parseChunkEditPath(edit.path).selector ?? edit.path) : "?";
|
||||
const op = edit.op ?? "replace";
|
||||
|
||||
switch (op) {
|
||||
@@ -303,7 +316,15 @@ function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme):
|
||||
return formatStreamingDiff(args.diff, rawPath, uiTheme);
|
||||
}
|
||||
if (args.edits && args.edits.length > 0) {
|
||||
return formatStreamingHashlineEdits(args.edits, uiTheme);
|
||||
// Only show hashline/chunk streaming edits — replace/patch use previewDiff above
|
||||
const first = args.edits[0];
|
||||
if (
|
||||
first &&
|
||||
typeof first === "object" &&
|
||||
("loc" in first || ("op" in first && "content" in first && !("old_text" in first) && !("diff" in first)))
|
||||
) {
|
||||
return formatStreamingHashlineEdits(args.edits, uiTheme);
|
||||
}
|
||||
}
|
||||
if (args.diff) {
|
||||
return renderPlainTextPreview(args.diff, uiTheme);
|
||||
@@ -377,11 +398,22 @@ export const editToolRenderer = {
|
||||
mergeCallAndResult: true,
|
||||
|
||||
renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const rawPath = args.file_path || args.path || "";
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename: args.rename });
|
||||
// Extract path from first edit entry when top-level path is absent (new schema)
|
||||
const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
|
||||
const rawPath = args.file_path || args.path || (firstEdit as any)?.path || "";
|
||||
const rename = args.rename || (firstEdit as any)?.rename;
|
||||
const op = args.op || (firstEdit as any)?.op;
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
const spinner =
|
||||
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
|
||||
let text = `${formatTitle(getOperationTitle(args.op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
|
||||
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
|
||||
// Show file count hint for multi-file edits
|
||||
const fileCount = Array.isArray(args.edits)
|
||||
? new Set((args.edits as any[]).map(e => e?.path).filter(Boolean)).size
|
||||
: 0;
|
||||
if (fileCount > 1) {
|
||||
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
|
||||
}
|
||||
text += getCallPreview(args, rawPath, uiTheme);
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
@@ -393,76 +425,126 @@ export const editToolRenderer = {
|
||||
uiTheme: Theme,
|
||||
args?: EditRenderArgs,
|
||||
): Component {
|
||||
const rawPath = args?.file_path || args?.path || "";
|
||||
const op = args?.op || result.details?.op;
|
||||
const rename = args?.rename || result.details?.move;
|
||||
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
|
||||
// Pre-compute metadata line (static across renders)
|
||||
const metadataLine =
|
||||
op !== "delete"
|
||||
? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
|
||||
: "";
|
||||
|
||||
// Pre-compute error text (static)
|
||||
const errorText = result.isError ? (result.content?.find(c => c.type === "text")?.text ?? "") : "";
|
||||
|
||||
let cached: RenderCache | undefined;
|
||||
|
||||
return {
|
||||
render(width) {
|
||||
const { expanded, renderContext } = options;
|
||||
const editDiffPreview = renderContext?.editDiffPreview;
|
||||
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
|
||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
||||
if (cached?.key === key) return cached.lines;
|
||||
|
||||
const firstChangedLine =
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview
|
||||
? editDiffPreview.firstChangedLine
|
||||
: undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined);
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
|
||||
|
||||
const header = renderStatusLine(
|
||||
{
|
||||
icon: result.isError ? "error" : "success",
|
||||
title: getOperationTitle(op),
|
||||
description,
|
||||
},
|
||||
uiTheme,
|
||||
);
|
||||
let text = header;
|
||||
text += metadataLine;
|
||||
|
||||
if (result.isError) {
|
||||
if (errorText) {
|
||||
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
|
||||
}
|
||||
} else if (result.details?.diff) {
|
||||
text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
} else if (editDiffPreview) {
|
||||
if ("error" in editDiffPreview) {
|
||||
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
|
||||
} else if (editDiffPreview.diff) {
|
||||
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
}
|
||||
}
|
||||
|
||||
// Show LSP diagnostics if available
|
||||
if (result.details?.diagnostics) {
|
||||
text += formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp: string) =>
|
||||
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|
||||
);
|
||||
}
|
||||
|
||||
const lines =
|
||||
width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
|
||||
cached = { key, lines };
|
||||
return lines;
|
||||
},
|
||||
invalidate() {
|
||||
cached = undefined;
|
||||
},
|
||||
};
|
||||
const perFileResults = result.details?.perFileResults;
|
||||
if (perFileResults && perFileResults.length > 1) {
|
||||
return renderMultiFileResult(perFileResults, options, uiTheme);
|
||||
}
|
||||
return renderSingleFileResult(result, options, uiTheme, args);
|
||||
},
|
||||
};
|
||||
|
||||
function renderSingleFileResult(
|
||||
result: {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
details?: EditToolDetails | EditToolPerFileResult;
|
||||
isError?: boolean;
|
||||
},
|
||||
options: RenderResultOptions & { renderContext?: EditRenderContext },
|
||||
uiTheme: Theme,
|
||||
args?: EditRenderArgs,
|
||||
): Component {
|
||||
const details = result.details;
|
||||
const isError = result.isError ?? (details && "isError" in details ? details.isError : false);
|
||||
const rawPath = args?.file_path || args?.path || (details && "path" in details ? details.path : "") || "";
|
||||
const op = args?.op || details?.op;
|
||||
const rename = args?.rename || details?.move;
|
||||
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
|
||||
const metadataLine =
|
||||
op !== "delete"
|
||||
? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
|
||||
: "";
|
||||
|
||||
const errorText = isError
|
||||
? (details && "errorText" in details && details.errorText) ||
|
||||
(result.content?.find(c => c.type === "text")?.text ?? "")
|
||||
: "";
|
||||
|
||||
let cached: RenderCache | undefined;
|
||||
|
||||
return {
|
||||
render(width) {
|
||||
const { expanded, renderContext } = options;
|
||||
const editDiffPreview = renderContext?.editDiffPreview;
|
||||
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
|
||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
||||
if (cached?.key === key) return cached.lines;
|
||||
|
||||
const firstChangedLine =
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
||||
(details && !isError ? details.firstChangedLine : undefined);
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
|
||||
|
||||
const header = renderStatusLine(
|
||||
{
|
||||
icon: isError ? "error" : "success",
|
||||
title: getOperationTitle(op),
|
||||
description,
|
||||
},
|
||||
uiTheme,
|
||||
);
|
||||
let text = header;
|
||||
text += metadataLine;
|
||||
|
||||
if (isError) {
|
||||
if (errorText) {
|
||||
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
|
||||
}
|
||||
} else if (details?.diff) {
|
||||
text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
} else if (editDiffPreview) {
|
||||
if ("error" in editDiffPreview) {
|
||||
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
|
||||
} else if (editDiffPreview.diff) {
|
||||
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
}
|
||||
}
|
||||
|
||||
if (details?.diagnostics) {
|
||||
text += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
|
||||
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|
||||
);
|
||||
}
|
||||
|
||||
const lines =
|
||||
width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
|
||||
cached = { key, lines };
|
||||
return lines;
|
||||
},
|
||||
invalidate() {
|
||||
cached = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderMultiFileResult(
|
||||
perFileResults: EditToolPerFileResult[],
|
||||
options: RenderResultOptions & { renderContext?: EditRenderContext },
|
||||
uiTheme: Theme,
|
||||
): Component {
|
||||
const fileComponents = perFileResults.map(fileResult =>
|
||||
renderSingleFileResult({ content: [], details: fileResult, isError: fileResult.isError }, options, uiTheme),
|
||||
);
|
||||
|
||||
let cached: RenderCache | undefined;
|
||||
|
||||
return {
|
||||
render(width) {
|
||||
const key = new Hasher().bool(options.expanded).u32(width).u32(perFileResults.length).digest();
|
||||
if (cached?.key === key) return cached.lines;
|
||||
|
||||
const allLines: string[] = [];
|
||||
for (let i = 0; i < fileComponents.length; i++) {
|
||||
if (i > 0) allLines.push("");
|
||||
allLines.push(...fileComponents[i].render(width));
|
||||
}
|
||||
|
||||
cached = { key, lines: allLines };
|
||||
return allLines;
|
||||
},
|
||||
invalidate() {
|
||||
cached = undefined;
|
||||
for (const c of fileComponents) c.invalidate();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,12 +179,32 @@ export class ToolExecutionComponent extends Container {
|
||||
#maybeComputeEditDiff(): void {
|
||||
if (this.#toolName !== "edit") return;
|
||||
|
||||
const path = this.#args?.path;
|
||||
const op = this.#args?.op;
|
||||
const edits = this.#args?.edits;
|
||||
if (!Array.isArray(edits) || edits.length === 0) return;
|
||||
|
||||
if (op) {
|
||||
const diff = this.#args?.diff;
|
||||
const rename = this.#args?.rename;
|
||||
const first = edits[0];
|
||||
if (!first || typeof first !== "object") return;
|
||||
|
||||
// Detect mode from first edit entry shape and compute preview for first file
|
||||
if ("old_text" in first && "new_text" in first) {
|
||||
// Replace mode
|
||||
const { path, old_text: oldText, new_text: newText, all } = first;
|
||||
if (!path || oldText === undefined || newText === undefined) return;
|
||||
|
||||
const argsKey = JSON.stringify({ path, oldText, newText, all });
|
||||
if (this.#editDiffArgsKey === argsKey) return;
|
||||
this.#editDiffArgsKey = argsKey;
|
||||
|
||||
computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result => {
|
||||
if (this.#editDiffArgsKey === argsKey) {
|
||||
this.#editDiffPreview = result;
|
||||
this.#updateDisplay();
|
||||
this.#ui.requestRender();
|
||||
}
|
||||
});
|
||||
} else if ("path" in first && ("diff" in first || ("op" in first && !("content" in first)))) {
|
||||
// Patch mode (has diff or op without content — chunk edits always have content)
|
||||
const { path, op, rename, diff } = first;
|
||||
if (!path) return;
|
||||
|
||||
const argsKey = JSON.stringify({ path, op, rename, diff });
|
||||
@@ -201,49 +221,26 @@ export class ToolExecutionComponent extends Container {
|
||||
this.#ui.requestRender();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const edits = this.#args?.edits;
|
||||
const move = this.#args?.move;
|
||||
if (path && Array.isArray(edits)) {
|
||||
const argsKey = JSON.stringify({ path, edits, move });
|
||||
} else if ("loc" in first && "path" in first) {
|
||||
// Hashline mode — group edits by path, preview first file
|
||||
const path = first.path;
|
||||
if (!path) return;
|
||||
const fileEdits = edits.filter((e: any) => e.path === path);
|
||||
const move = this.#args?.move;
|
||||
|
||||
const argsKey = JSON.stringify({ path, edits: fileEdits, move });
|
||||
if (this.#editDiffArgsKey === argsKey) return;
|
||||
this.#editDiffArgsKey = argsKey;
|
||||
|
||||
computeHashlineDiff({ path, edits, move }, this.#cwd).then(result => {
|
||||
computeHashlineDiff({ path, edits: fileEdits, move }, this.#cwd).then(result => {
|
||||
if (this.#editDiffArgsKey === argsKey) {
|
||||
this.#editDiffPreview = result;
|
||||
this.#updateDisplay();
|
||||
this.#ui.requestRender();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const oldText = this.#args?.old_text;
|
||||
const newText = this.#args?.new_text;
|
||||
const all = this.#args?.all;
|
||||
|
||||
// Need all three params to compute diff
|
||||
if (!path || oldText === undefined || newText === undefined) return;
|
||||
|
||||
// Create a key to track which args this computation is for
|
||||
const argsKey = JSON.stringify({ path, oldText, newText, all });
|
||||
|
||||
// Skip if we already computed for these exact args
|
||||
if (this.#editDiffArgsKey === argsKey) return;
|
||||
|
||||
this.#editDiffArgsKey = argsKey;
|
||||
|
||||
// Compute diff async
|
||||
computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result => {
|
||||
// Only update if args haven't changed since we started
|
||||
if (this.#editDiffArgsKey === argsKey) {
|
||||
this.#editDiffPreview = result;
|
||||
this.#updateDisplay();
|
||||
this.#ui.requestRender();
|
||||
}
|
||||
});
|
||||
// Chunk mode edits don't have a pre-execution diff preview
|
||||
}
|
||||
|
||||
updateResult(
|
||||
|
||||
@@ -638,7 +638,8 @@ export class EventController {
|
||||
if (this.ctx.isBackgrounded === false) return;
|
||||
const notify = settings.get("completion.notify");
|
||||
if (notify === "off") return;
|
||||
const title = this.ctx.sessionManager.titleSource === "auto" ? undefined : this.ctx.sessionManager.getSessionName();
|
||||
const title =
|
||||
this.ctx.sessionManager.titleSource === "auto" ? undefined : this.ctx.sessionManager.getSessionName();
|
||||
const message = title ? `${title}: Complete` : "Complete";
|
||||
TERMINAL.sendNotification(message);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit
|
||||
|
||||
<rules>
|
||||
- **MUST** `read` first. Never invent chunk paths or CRCs. Copy them from the latest `read` output or edit response.
|
||||
- `sel` format:
|
||||
- insertions: `chunk`, `chunk~`, or `chunk^`
|
||||
- replacements: `chunk#CRC`, `chunk#CRC~`, or `chunk#CRC^`
|
||||
- `path` format: `file:selector` where selector is:
|
||||
- insertions: `file:chunk`, `file:chunk~`, or `file:chunk^`
|
||||
- replacements: `file:chunk#CRC`, `file:chunk#CRC~`, or `file:chunk#CRC^`
|
||||
- Without a suffix it defaults to the entire chunk including leading trivia. `~` targets the body, `^` targets the head.
|
||||
- If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing.
|
||||
{{#if chunkAutoIndent}}
|
||||
@@ -35,19 +35,11 @@ You **MUST** use the narrowest region that covers your change. Replacing without
|
||||
</critical>
|
||||
|
||||
<regions>
|
||||
Given a chunk like:
|
||||
```
|
||||
/// doc comment <-- leading trivia
|
||||
#[attr] <-- leading trivia
|
||||
fn foo(x: i32) { <-- signature + opening delimiter
|
||||
body(); <-- body
|
||||
} <-- closing delimiter
|
||||
```
|
||||
In `read` output, lines marked `^` between the line number and `|` are **head** lines (doc comments, attributes/decorators, signature). Lines without `^` are **body** lines. Use this to decide which region to target:
|
||||
|
||||
Append `~` to target the body, `^` to target the head (trivia + signature), or nothing for the whole chunk:
|
||||
- `fn_foo#CRC~` — body only. **Use for most edits.** On leaf chunks, falls back to whole chunk.
|
||||
- `fn_foo#CRC^` — head (decorators, attributes, doc comments, signature, opening delimiter).
|
||||
- `fn_foo#CRC` — entire chunk including leading trivia.
|
||||
- `fn_foo#CRC~` — **body only (the default choice for most edits).** Head lines (`^`) are preserved automatically — doc comments, attributes, and signature stay untouched. On leaf chunks, falls back to whole chunk.
|
||||
- `fn_foo#CRC^` — head only (decorators, attributes, doc comments, signature, opening delimiter). Body stays untouched.
|
||||
- `fn_foo#CRC` — entire chunk including leading trivia. **You must include doc comments and attributes in `content`; omitting them deletes them.**
|
||||
- `chunk~` + `append`/`prepend` inserts *inside* the container. `chunk` + `append`/`prepend` inserts *outside*.
|
||||
|
||||
**Note on leading trivia:** whether a decorator/doc comment belongs to `^` depends on the parser. In Rust and Python, attributes and decorators are attached to the function chunk, so `^` covers them. In TypeScript/JavaScript, a `@decorator` + `/** jsdoc */` block immediately above a method often surfaces as a **separate sibling chunk** (shown as `chunk#CRC` in the `?` listing) rather than as part of the function's `^`. If you need to rewrite a decorator, check the `?` listing for a sibling `chunk#CRC` directly above your target.
|
||||
@@ -56,183 +48,209 @@ Append `~` to target the body, `^` to target the head (trivia + signature), or n
|
||||
</regions>
|
||||
|
||||
<ops>
|
||||
|op|sel|effect|
|
||||
Each edit entry has: `{ op, path, content }` where `path` is `file:selector`.
|
||||
|
||||
|op|path (selector part)|effect|
|
||||
|---|---|---|
|
||||
|`replace`|`chunk#CRC`, `chunk#CRC~`, or `chunk#CRC^`|rewrite the addressed region|
|
||||
|`before`|`chunk`, `chunk~`, or `chunk^`|insert before the region span|
|
||||
|`after`|`chunk`, `chunk~`, or `chunk^`|insert after the region span|
|
||||
|`prepend`|`chunk`, `chunk~`, or `chunk^`|insert at the start inside the region|
|
||||
|`append`|`chunk`, `chunk~`, or `chunk^`|insert at the end inside the region|
|
||||
|`replace`|`file:chunk#CRC`, `file:chunk#CRC~`, or `file:chunk#CRC^`|rewrite the addressed region|
|
||||
|`before`|`file:chunk`, `file:chunk~`, or `file:chunk^`|insert before the region span|
|
||||
|`after`|`file:chunk`, `file:chunk~`, or `file:chunk^`|insert after the region span|
|
||||
|`prepend`|`file:chunk`, `file:chunk~`, or `file:chunk^`|insert at the start inside the region|
|
||||
|`append`|`file:chunk`, `file:chunk~`, or `file:chunk^`|insert at the end inside the region|
|
||||
</ops>
|
||||
|
||||
<examples>
|
||||
Given this `read` output for `example.ts`:
|
||||
Given this `read` output for `counter.rs`:
|
||||
```
|
||||
| example.ts·34L·ts·#QBMH
|
||||
|
|
||||
| [<interface_Config#BWTR>]
|
||||
1|interface Config {
|
||||
| [<interface_Config.field_host#TTMN>]
|
||||
2| host: string;
|
||||
| [<interface_Config.field_port#QSMH>]
|
||||
3| port: number;
|
||||
| [<interface_Config.field_debug#JPRR>]
|
||||
4| debug: boolean;
|
||||
5|}
|
||||
|
|
||||
| [<class_Counter#HZHY>]
|
||||
7|class Counter {
|
||||
| [<class_Counter.field_value#QJBY>]
|
||||
8| value: number = 0;
|
||||
9|
|
||||
| [<class_Counter.fn_increment#NQWY>]
|
||||
10| increment(): void {
|
||||
11| this.value += 1;
|
||||
12| }
|
||||
13|
|
||||
| [<class_Counter.fn_decrement#PMBP>]
|
||||
14| decrement(): void {
|
||||
15| this.value -= 1;
|
||||
16| }
|
||||
17|
|
||||
| [<class_Counter.fn_toString#ZQZP>]
|
||||
18| toString(): string {
|
||||
19| return `Counter(${this.value})`;
|
||||
20| }
|
||||
21|}
|
||||
|
|
||||
| [<enum_Status#HYQJ>]
|
||||
23|enum Status {
|
||||
| [<enum_Status.variant_Active#PQNS>]
|
||||
24| Active = "ACTIVE",
|
||||
| [<enum_Status.variant_Paused#HHNM>]
|
||||
25| Paused = "PAUSED",
|
||||
| [<enum_Status.variant_Stopped#NHTY>]
|
||||
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|}
|
||||
| counter.rs·62L·rust·#ZRPW
|
||||
|
|
||||
| [<imp#MNHH>]
|
||||
1 |use std::fmt;
|
||||
|
|
||||
| [<struct_Counte#QTSX>]
|
||||
3^|/// A simple counter that tracks a value and its history.
|
||||
4^|#[derive(Debug, Clone)]
|
||||
5^|pub struct Counter {
|
||||
| [<struct_Counte.field_value#MQTW>]
|
||||
6 | /// The current value.
|
||||
7 | value: i32,
|
||||
| [<struct_Counte.field_max#HJMQ>]
|
||||
8 | /// Maximum allowed value.
|
||||
9 | max: i32,
|
||||
10 |}
|
||||
|
|
||||
| [<impl_Counte#VNPP>]
|
||||
12^|impl Counter {
|
||||
| [<impl_Counte.fn_new#RWZV>]
|
||||
13^| /// Creates a new counter starting at zero.
|
||||
14^| pub fn new(max: i32) -> Self {
|
||||
15 | Self { value: 0, max }
|
||||
16 | }
|
||||
17 |
|
||||
| [<impl_Counte.fn_increm#MNHV>]
|
||||
18^| /// Increments the counter by one, clamping at max.
|
||||
19^| pub fn increment(&mut self) {
|
||||
20 | if self.value < self.max {
|
||||
21 | self.value += 1;
|
||||
22 | }
|
||||
23 | }
|
||||
24 |
|
||||
| [<impl_Counte.fn_decrem#TTWB>]
|
||||
25^| /// Decrements the counter by one, clamping at zero.
|
||||
26^| pub fn decrement(&mut self) {
|
||||
27 | if self.value > 0 {
|
||||
28 | self.value -= 1;
|
||||
29 | }
|
||||
30 | }
|
||||
31 |
|
||||
| [<impl_Counte.fn_get#PTNT>]
|
||||
32^| /// Returns the current value.
|
||||
33^| pub fn get(&self) -> i32 {
|
||||
34 | self.value
|
||||
35 | }
|
||||
36 |}
|
||||
|
|
||||
| [<impl_Displa#BNJH>]
|
||||
38^|impl fmt::Display for Counter {
|
||||
| [<impl_Displa.fn_fmt#NKRN>]
|
||||
39^| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
40 | write!(f, "Counter({}/{})", self.value, self.max)
|
||||
41 | }
|
||||
42 |}
|
||||
|
|
||||
| [<mod_tests#YWXM>]
|
||||
44^|#[cfg(test)]
|
||||
45^|mod tests {
|
||||
| [<mod_tests.chunk#VSMY>]
|
||||
46 | use super::*;
|
||||
47 |
|
||||
| [<mod_tests.fn_test_i#YXQZ>]
|
||||
48^| #[test]
|
||||
49^| fn test_increment() {
|
||||
50 | let mut c = Counter::new(10);
|
||||
51 | c.increment();
|
||||
52 | assert_eq!(c.get(), 1);
|
||||
53 | }
|
||||
54 |
|
||||
| [<mod_tests.fn_test_d#XPBQ>]
|
||||
55^| #[test]
|
||||
56^| fn test_decrement_at_zero() {
|
||||
57 | let mut c = Counter::new(10);
|
||||
58 | c.decrement();
|
||||
59 | assert_eq!(c.get(), 0);
|
||||
60 | }
|
||||
61 |}
|
||||
```
|
||||
|
||||
**Replace a whole chunk** (rename a function):
|
||||
~~~json
|
||||
{{#if chunkAutoIndent}}
|
||||
{ "sel": "fn_createCounter#PQQY", "op": "replace", "content": "function makeCounter(start: number): Counter {\n\tconst c = new Counter();\n\tc.value = start;\n\treturn c;\n}\n" }
|
||||
{{else}}
|
||||
{ "sel": "fn_createCounter#PQQY", "op": "replace", "content": "function makeCounter(start: number): Counter {\n const c = new Counter();\n c.value = start;\n return c;\n}\n" }
|
||||
{{/if}}
|
||||
~~~
|
||||
Result — the entire chunk is rewritten:
|
||||
**Understanding `^` markers in `read` output:** Lines marked with `^` between the line number and `|` (e.g. ` 3^|`) are **head** lines — doc comments, attributes, and the signature. Lines without `^` (e.g. ` 7 |`) are **body** lines. `~` replaces body lines only, keeping head lines intact.
|
||||
|
||||
**Replace a method body** (`~` — the common case):
|
||||
```
|
||||
function makeCounter(start: number): Counter {
|
||||
const c = new Counter();
|
||||
c.value = start;
|
||||
return c;
|
||||
}
|
||||
{ "path": "counter.rs:impl_Counte.fn_increm#MNHV~", "op": "replace", "content": "self.value = (self.value + 1).min(self.max);\n" }
|
||||
```
|
||||
Result — only the body (non-`^` lines) changes; the doc comment `/// Increments the counter...`, the signature `pub fn increment(&mut self) {`, and the closing `}` are all preserved:
|
||||
```
|
||||
/// Increments the counter by one, clamping at max.
|
||||
pub fn increment(&mut self) {
|
||||
self.value = (self.value + 1).min(self.max);
|
||||
}
|
||||
```
|
||||
|
||||
**Replace a method body** (`~`):
|
||||
**Replace a whole chunk** (rewrite signature + doc comment + body):
|
||||
```
|
||||
{ "sel": "class_Counter.fn_increment#NQWY~", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" }
|
||||
{ "path": "counter.rs:impl_Counte.fn_increm#MNHV", "op": "replace", "content": "/// Increments by the given step, clamping at max.\npub fn increment(&mut self, step: i32) {\n\tself.value = (self.value + step).min(self.max);\n}\n" }
|
||||
```
|
||||
Result — only the body changes, signature and braces are kept:
|
||||
Result — **everything** including the doc comment and signature is rewritten. You must include the doc comment and signature in `content`; omitting them deletes them:
|
||||
```
|
||||
increment(): void {
|
||||
this.value += 1;
|
||||
console.log('incremented to', this.value);
|
||||
}
|
||||
/// Increments by the given step, clamping at max.
|
||||
pub fn increment(&mut self, step: i32) {
|
||||
self.value = (self.value + step).min(self.max);
|
||||
}
|
||||
```
|
||||
|
||||
**Replace a function header** (`^` — signature and doc comment):
|
||||
**Replace a function header** (`^` — attributes, doc comments, signature):
|
||||
```
|
||||
{ "sel": "fn_createCounter#PQQY^", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" }
|
||||
{ "path": "counter.rs:impl_Counte.fn_get#PTNT^", "op": "replace", "content": "/// Returns the current counter value.\n#[inline]\npub fn get(&self) -> i32 {\n" }
|
||||
```
|
||||
Result — adds a doc comment and updates the signature, body untouched:
|
||||
Result — the head (all `^` lines + opening brace) changes, body untouched:
|
||||
```
|
||||
/** Creates a counter with the given start value. */
|
||||
function createCounter(initial: number, label?: string): Counter {
|
||||
const counter = new Counter();
|
||||
counter.value = initial;
|
||||
return counter;
|
||||
}
|
||||
/// Returns the current counter value.
|
||||
#[inline]
|
||||
pub fn get(&self) -> i32 {
|
||||
self.value
|
||||
}
|
||||
```
|
||||
|
||||
**Insert before a chunk** (`before`):
|
||||
```
|
||||
{ "sel": "fn_createCounter", "op": "before", "content": "/** Factory function below. */\n" }
|
||||
{ "path": "counter.rs:impl_Counte.fn_get", "op": "before", "content": "/// Resets the counter to zero.\npub fn reset(&mut self) {\n\tself.value = 0;\n}\n\n" }
|
||||
```
|
||||
Result — a comment is inserted before the function:
|
||||
Result — a new method is inserted before `fn get`:
|
||||
```
|
||||
/** Factory function below. */
|
||||
/// Resets the counter to zero.
|
||||
pub fn reset(&mut self) {
|
||||
self.value = 0;
|
||||
}
|
||||
|
||||
function createCounter(initial: number): Counter {
|
||||
/// Returns the current value.
|
||||
pub fn get(&self) -> i32 {
|
||||
```
|
||||
|
||||
**Insert after a chunk** (`after`):
|
||||
~~~json
|
||||
{{#if chunkAutoIndent}}
|
||||
{ "sel": "enum_Status", "op": "after", "content": "\nfunction isActive(s: Status): boolean {\n\treturn s === Status.Active;\n}\n" }
|
||||
{{else}}
|
||||
{ "sel": "enum_Status", "op": "after", "content": "\nfunction isActive(s: Status): boolean {\n return s === Status.Active;\n}\n" }
|
||||
{{/if}}
|
||||
~~~
|
||||
Result — a new function appears after the enum:
|
||||
```
|
||||
enum Status {
|
||||
Active = "ACTIVE",
|
||||
Paused = "PAUSED",
|
||||
Stopped = "STOPPED",
|
||||
{ "path": "counter.rs:struct_Counte", "op": "after", "content": "\nimpl Default for Counter {\n\tfn default() -> Self {\n\t\tSelf { value: 0, max: 100 }\n\t}\n}\n" }
|
||||
```
|
||||
Result — a new impl block appears after the struct:
|
||||
```
|
||||
}
|
||||
|
||||
function isActive(s: Status): boolean {
|
||||
return s === Status.Active;
|
||||
impl Default for Counter {
|
||||
fn default() -> Self {
|
||||
Self { value: 0, max: 100 }
|
||||
}
|
||||
}
|
||||
|
||||
function createCounter(initial: number): Counter {
|
||||
impl Counter {
|
||||
```
|
||||
|
||||
**Prepend inside a container** (`~` + `prepend`):
|
||||
```
|
||||
{ "sel": "class_Counter~", "op": "prepend", "content": "label: string = 'default';\n\n" }
|
||||
{ "path": "counter.rs:impl_Counte~", "op": "prepend", "content": "/// Creates a counter starting at the given value.\npub fn with_value(value: i32, max: i32) -> Self {\n\tSelf { value: value.min(max), max }\n}\n\n" }
|
||||
```
|
||||
Result — a new field is added at the top of the class body, before existing members:
|
||||
Result — a new method is added at the top of the impl body, before existing methods:
|
||||
```
|
||||
class Counter {
|
||||
label: string = 'default';
|
||||
impl Counter {
|
||||
/// Creates a counter starting at the given value.
|
||||
pub fn with_value(value: i32, max: i32) -> Self {
|
||||
Self { value: value.min(max), max }
|
||||
}
|
||||
|
||||
value: number = 0;
|
||||
/// Creates a new counter starting at zero.
|
||||
pub fn new(max: i32) -> Self {
|
||||
```
|
||||
|
||||
**Append inside a container** (`~` + `append`):
|
||||
~~~json
|
||||
{{#if chunkAutoIndent}}
|
||||
{ "sel": "class_Counter~", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" }
|
||||
{{else}}
|
||||
{ "sel": "class_Counter~", "op": "append", "content": "\nreset(): void {\n this.value = 0;\n}\n" }
|
||||
{{/if}}
|
||||
~~~
|
||||
Result — a new method is added at the end of the class body, before the closing `}`:
|
||||
```
|
||||
toString(): string {
|
||||
return `Counter(${this.value})`;
|
||||
}
|
||||
{ "path": "counter.rs:impl_Counte~", "op": "append", "content": "\n/// Returns true if the counter is at its maximum.\npub fn is_maxed(&self) -> bool {\n\tself.value >= self.max\n}\n" }
|
||||
```
|
||||
Result — a new method is added at the end of the impl body, before the closing `}`:
|
||||
```
|
||||
pub fn get(&self) -> i32 {
|
||||
self.value
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.value = 0;
|
||||
}
|
||||
/// Returns true if the counter is at its maximum.
|
||||
pub fn is_maxed(&self) -> bool {
|
||||
self.value >= self.max
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Delete a chunk** (`replace` with empty content):
|
||||
```
|
||||
{ "sel": "class_Counter.fn_toString#ZQZP", "op": "replace", "content": "" }
|
||||
{ "path": "counter.rs:impl_Counte.fn_decrem#TTWB", "op": "replace", "content": "" }
|
||||
```
|
||||
Result — the method is removed from the class.
|
||||
Result — the method (including its doc comment and signature) is removed.
|
||||
- Indentation rules (important):
|
||||
{{#if chunkAutoIndent}}
|
||||
- Use `\t` for each indent level. The tool converts tabs to the file's actual style (2-space, 4-space, etc.).
|
||||
@@ -241,11 +259,11 @@ Result — the method is removed from the class.
|
||||
{{/if}}
|
||||
- Do NOT include the chunk's base indentation — only indent relative to the region's opening level.
|
||||
- For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in, and the tool adds the method's base indent to both.
|
||||
- For `^`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`.
|
||||
- For `^`: write at the chunk's own depth. A class member's head uses `"/// doc\n#[attr]\npub fn start() {"`.
|
||||
{{#if chunkAutoIndent}}
|
||||
- For a top-level item: start at zero indent. Write `"function foo() {\n\treturn 1;\n}\n"`.
|
||||
- For a top-level item: start at zero indent. Write `"fn foo() {\n\treturn 1;\n}\n"`.
|
||||
{{else}}
|
||||
- For a top-level item: start at zero indent. Write `"function foo() {\n return 1;\n}\n"`.
|
||||
- For a top-level item: start at zero indent. Write `"fn foo() {\n return 1;\n}\n"`.
|
||||
{{/if}}
|
||||
- The tool strips common leading indentation from your content as a safety net, so accidental over-indentation is corrected.
|
||||
</examples>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
Applies precise file edits using `LINE#ID` anchors from `read` output.
|
||||
|
||||
Read the file first. Copy anchors exactly from the latest `read` output. In one `edit` call, batch all edits for one file. After any successful edit, re-read before editing that file again.
|
||||
Read the file first. Copy anchors exactly from the latest `read` output. After any successful edit, re-read before editing that file again.
|
||||
|
||||
<operations>
|
||||
**Top level**
|
||||
- `path` — file path
|
||||
- `move` — optional rename target
|
||||
- `delete` — optional whole-file delete
|
||||
- `edits` — array of `{ loc, content }` entries
|
||||
- `edits` — array of edit entries
|
||||
|
||||
**Edit entry**: `{ loc, content }`
|
||||
**Edit entry**: `{ path, loc, content }` or `{ path, delete: true }` or `{ path, move: "new/path" }`
|
||||
- `path` — file path
|
||||
- `loc` — where to apply the edit (see below)
|
||||
- `content` — replacement/inserted lines (array of strings preferred, `null` to delete)
|
||||
- `delete` — delete the file
|
||||
- `move` — move/rename the file
|
||||
|
||||
**`loc` values**
|
||||
- `"append"` / `"prepend"` — insert at end/start of file
|
||||
@@ -46,8 +46,8 @@ All examples below reference the same file:
|
||||
Replace only the catch body. Do not target the shared boundary line `} catch (err) {`.
|
||||
```
|
||||
{
|
||||
path: "a.ts",
|
||||
edits: [{
|
||||
path: "a.ts",
|
||||
loc: { range: { pos: {{href 15 "\t\tconsole.error(err);"}}, end: {{href 16 "\t\treturn null;"}} } },
|
||||
content: [
|
||||
"\t\tif (isEnoent(err)) return null;",
|
||||
@@ -62,8 +62,8 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er
|
||||
Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`.
|
||||
```
|
||||
{
|
||||
path: "a.ts",
|
||||
edits: [{
|
||||
path: "a.ts",
|
||||
loc: { range: { pos: {{href 6 "\tlog();"}}, end: {{href 7 "}"}} } },
|
||||
content: [
|
||||
"\tvalidate();",
|
||||
@@ -79,8 +79,8 @@ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be
|
||||
<example name="replace one line">
|
||||
```
|
||||
{
|
||||
path: "a.ts",
|
||||
edits: [{
|
||||
path: "a.ts",
|
||||
loc: { range: { pos: {{href 2 "const timeout = 5000;"}}, end: {{href 2 "const timeout = 5000;"}} } },
|
||||
content: ["const timeout = 30_000;"]
|
||||
}]
|
||||
@@ -91,8 +91,8 @@ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be
|
||||
<example name="delete a range">
|
||||
```
|
||||
{
|
||||
path: "a.ts",
|
||||
edits: [{
|
||||
path: "a.ts",
|
||||
loc: { range: { pos: {{href 10 "\t// TODO: remove after migration"}}, end: {{href 11 "\tlegacy();"}} } },
|
||||
content: null
|
||||
}]
|
||||
@@ -104,8 +104,8 @@ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be
|
||||
When adding a sibling declaration, prefer `prepend` on the next declaration.
|
||||
```
|
||||
{
|
||||
path: "a.ts",
|
||||
edits: [{
|
||||
path: "a.ts",
|
||||
loc: { prepend: {{href 9 "function beta() {"}} },
|
||||
content: [
|
||||
"function gamma() {",
|
||||
|
||||
@@ -18,7 +18,8 @@ When editing structured blocks (nested braces, tags, indented regions), include
|
||||
|
||||
<parameters>
|
||||
```ts
|
||||
type T =
|
||||
// Input is { edits: Entry[] } where Entry is one of:
|
||||
type Entry =
|
||||
// Diff is one or more hunks in the same file.
|
||||
// - Each hunk begins with "@@" (anchor optional).
|
||||
// - Each hunk body only has lines starting with ' ' | '+' | '-'.
|
||||
@@ -50,19 +51,23 @@ Returns success/failure; on failure, error message indicates:
|
||||
</critical>
|
||||
|
||||
<example name="create">
|
||||
edit {"path":"hello.txt","op":"create","diff":"Hello\n"}
|
||||
edit {"edits":[{"path":"hello.txt","op":"create","diff":"Hello\n"}]}
|
||||
</example>
|
||||
|
||||
<example name="update">
|
||||
edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}
|
||||
edit {"edits":[{"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}]}
|
||||
</example>
|
||||
|
||||
<example name="rename">
|
||||
edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"}
|
||||
edit {"edits":[{"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"}]}
|
||||
</example>
|
||||
|
||||
<example name="delete">
|
||||
edit {"path":"obsolete.txt","op":"delete"}
|
||||
edit {"edits":[{"path":"obsolete.txt","op":"delete"}]}
|
||||
</example>
|
||||
|
||||
<example name="multi-file">
|
||||
edit {"edits":[{"path":"src/types.ts","op":"update","diff":"@@\n-old\n+new\n"},{"path":"src/index.ts","op":"update","diff":"@@\n-old\n+new\n"}]}
|
||||
</example>
|
||||
|
||||
<avoid>
|
||||
|
||||
@@ -316,7 +316,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
outputLines.push(` ${anchor}`);
|
||||
}
|
||||
for (const match of chunkMatches) {
|
||||
outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")}|${match.line}`);
|
||||
outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
|
||||
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ describe("computeHashlineDiff", () => {
|
||||
await Bun.write(sourcePath, "first\n");
|
||||
|
||||
const result = await computeHashlineDiff(
|
||||
{ path: sourcePath, edits: [{ loc: "append", content: "second" }] },
|
||||
{ path: sourcePath, edits: [{ path: sourcePath, loc: "append", content: "second" }] },
|
||||
tempDir,
|
||||
);
|
||||
expect("diff" in result).toBe(true);
|
||||
|
||||
@@ -667,9 +667,7 @@ describe("Coding Agent Tools", () => {
|
||||
fs.writeFileSync(testFile, originalContent);
|
||||
|
||||
const result = await editTool.execute("test-call-5", {
|
||||
path: testFile,
|
||||
old_text: "world",
|
||||
new_text: "testing",
|
||||
edits: [{ path: testFile, old_text: "world", new_text: "testing" }],
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Successfully replaced");
|
||||
@@ -686,9 +684,7 @@ describe("Coding Agent Tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("test-call-6", {
|
||||
path: testFile,
|
||||
old_text: "nonexistent",
|
||||
new_text: "testing",
|
||||
edits: [{ path: testFile, old_text: "nonexistent", new_text: "testing" }],
|
||||
}),
|
||||
).rejects.toThrow(/Could not find/);
|
||||
});
|
||||
@@ -700,9 +696,7 @@ describe("Coding Agent Tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("test-call-7", {
|
||||
path: testFile,
|
||||
old_text: "foo",
|
||||
new_text: "bar",
|
||||
edits: [{ path: testFile, old_text: "foo", new_text: "bar" }],
|
||||
}),
|
||||
).rejects.toThrow(/Found 3 occurrences/);
|
||||
});
|
||||
@@ -712,10 +706,7 @@ describe("Coding Agent Tools", () => {
|
||||
fs.writeFileSync(testFile, "foo bar foo baz foo");
|
||||
|
||||
const result = await editTool.execute("test-all-1", {
|
||||
path: testFile,
|
||||
old_text: "foo",
|
||||
new_text: "qux",
|
||||
all: true,
|
||||
edits: [{ path: testFile, old_text: "foo", new_text: "qux", all: true }],
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Successfully replaced 3 occurrences");
|
||||
@@ -744,10 +735,14 @@ function b() {
|
||||
// With multiple fuzzy matches, the tool rejects for safety to avoid ambiguous replacements
|
||||
await expect(
|
||||
editTool.execute("test-all-fuzzy", {
|
||||
path: testFile,
|
||||
old_text: "if (x) {\n doThing();\n}",
|
||||
new_text: "if (y) {\n doOther();\n}",
|
||||
all: true,
|
||||
edits: [
|
||||
{
|
||||
path: testFile,
|
||||
old_text: "if (x) {\n doThing();\n}",
|
||||
new_text: "if (y) {\n doOther();\n}",
|
||||
all: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/Found 2 high-confidence matches/);
|
||||
});
|
||||
@@ -758,10 +753,7 @@ function b() {
|
||||
|
||||
await expect(
|
||||
editTool.execute("test-all-nomatch", {
|
||||
path: testFile,
|
||||
old_text: "nonexistent",
|
||||
new_text: "bar",
|
||||
all: true,
|
||||
edits: [{ path: testFile, old_text: "nonexistent", new_text: "bar", all: true }],
|
||||
}),
|
||||
).rejects.toThrow(/Could not find/);
|
||||
});
|
||||
@@ -771,10 +763,7 @@ function b() {
|
||||
fs.writeFileSync(testFile, "start\nfoo\nbar\nend\nstart\nfoo\nbar\nend");
|
||||
|
||||
const result = await editTool.execute("test-all-multiline", {
|
||||
path: testFile,
|
||||
old_text: "foo\nbar",
|
||||
new_text: "replaced",
|
||||
all: true,
|
||||
edits: [{ path: testFile, old_text: "foo\nbar", new_text: "replaced", all: true }],
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Successfully replaced 2 occurrences");
|
||||
@@ -787,10 +776,7 @@ function b() {
|
||||
fs.writeFileSync(testFile, "hello world");
|
||||
|
||||
const result = await editTool.execute("test-all-single", {
|
||||
path: testFile,
|
||||
old_text: "world",
|
||||
new_text: "universe",
|
||||
all: true,
|
||||
edits: [{ path: testFile, old_text: "world", new_text: "universe", all: true }],
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Successfully replaced text");
|
||||
@@ -1455,9 +1441,7 @@ describe("edit tool CRLF handling", () => {
|
||||
fs.writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n");
|
||||
|
||||
const result = await editTool.execute("test-crlf-1", {
|
||||
path: testFile,
|
||||
old_text: "line two\n",
|
||||
new_text: "replaced line\n",
|
||||
edits: [{ path: testFile, old_text: "line two\n", new_text: "replaced line\n" }],
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Successfully replaced");
|
||||
@@ -1468,9 +1452,7 @@ describe("edit tool CRLF handling", () => {
|
||||
fs.writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n");
|
||||
|
||||
await editTool.execute("test-crlf-2", {
|
||||
path: testFile,
|
||||
old_text: "second\n",
|
||||
new_text: "REPLACED\n",
|
||||
edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }],
|
||||
});
|
||||
|
||||
const content = await Bun.file(testFile).text();
|
||||
@@ -1482,9 +1464,7 @@ describe("edit tool CRLF handling", () => {
|
||||
fs.writeFileSync(testFile, "first\nsecond\nthird\n");
|
||||
|
||||
await editTool.execute("test-lf-1", {
|
||||
path: testFile,
|
||||
old_text: "second\n",
|
||||
new_text: "REPLACED\n",
|
||||
edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }],
|
||||
});
|
||||
|
||||
const content = await Bun.file(testFile).text();
|
||||
@@ -1498,9 +1478,7 @@ describe("edit tool CRLF handling", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("test-crlf-dup", {
|
||||
path: testFile,
|
||||
old_text: "hello\nworld\n",
|
||||
new_text: "replaced\n",
|
||||
edits: [{ path: testFile, old_text: "hello\nworld\n", new_text: "replaced\n" }],
|
||||
}),
|
||||
).rejects.toThrow(/Found 2 occurrences/);
|
||||
});
|
||||
@@ -1518,10 +1496,8 @@ describe("edit tool CRLF handling", () => {
|
||||
const session = createTestToolSession(hashDir);
|
||||
const hashlineEditTool = new EditTool(session);
|
||||
const result = await hashlineEditTool.execute("hashline-delete-1", {
|
||||
path: testFile,
|
||||
edits: [],
|
||||
delete: true,
|
||||
});
|
||||
edits: [{ path: testFile, delete: true }],
|
||||
} as any);
|
||||
|
||||
expect(getTextOutput(result)).toContain("Deleted");
|
||||
expect(fs.existsSync(testFile)).toBe(false);
|
||||
@@ -1546,10 +1522,8 @@ describe("edit tool CRLF handling", () => {
|
||||
const session = createTestToolSession(hashDir);
|
||||
const hashlineEditTool = new EditTool(session);
|
||||
const result = await hashlineEditTool.execute("hashline-rename-1", {
|
||||
path: sourceFile,
|
||||
edits: [],
|
||||
move: targetFile,
|
||||
});
|
||||
edits: [{ path: sourceFile, move: targetFile }],
|
||||
} as any);
|
||||
|
||||
expect(getTextOutput(result)).toContain("Moved");
|
||||
expect(fs.existsSync(sourceFile)).toBe(false);
|
||||
@@ -1577,10 +1551,8 @@ describe("edit tool CRLF handling", () => {
|
||||
const session = createTestToolSession(hashDir);
|
||||
const hashlineEditTool = new EditTool(session);
|
||||
const result = await hashlineEditTool.execute("hashline-rename-binary", {
|
||||
path: sourceFile,
|
||||
edits: [],
|
||||
move: targetFile,
|
||||
});
|
||||
edits: [{ path: sourceFile, move: targetFile }],
|
||||
} as any);
|
||||
|
||||
expect(getTextOutput(result)).toContain("Moved");
|
||||
expect(fs.existsSync(sourceFile)).toBe(false);
|
||||
@@ -1599,9 +1571,7 @@ describe("edit tool CRLF handling", () => {
|
||||
fs.writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n");
|
||||
|
||||
await editTool.execute("test-bom", {
|
||||
path: testFile,
|
||||
old_text: "second\n",
|
||||
new_text: "REPLACED\n",
|
||||
edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }],
|
||||
});
|
||||
|
||||
const content = await Bun.file(testFile).text();
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -187,9 +187,9 @@ describe("chunk mode tools", () => {
|
||||
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).toContain("64|\t\treturn err.message + total;");
|
||||
expect(text).toContain("3 |");
|
||||
expect(text).toContain("4 |");
|
||||
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");
|
||||
@@ -209,8 +209,8 @@ describe("chunk mode tools", () => {
|
||||
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("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");
|
||||
@@ -224,12 +224,12 @@ describe("chunk mode tools", () => {
|
||||
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("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]");
|
||||
});
|
||||
@@ -297,7 +297,7 @@ describe("chunk mode tools", () => {
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("[<class_Server.fn_handle.ret#");
|
||||
expect(text).toContain("64| return err.message + total;");
|
||||
expect(text).toContain("64 | return err.message + total;");
|
||||
expect(text).toContain("err.message");
|
||||
});
|
||||
|
||||
@@ -317,7 +317,7 @@ 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 () => {
|
||||
@@ -345,11 +345,11 @@ describe("chunk mode tools", () => {
|
||||
expect(text).toContain("# src/tools");
|
||||
expect(text).toContain("## └─ grep.ts");
|
||||
expect(text).toContain(" [<fn_execut#");
|
||||
expect(text).toContain(' 2| return "needle";');
|
||||
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";');
|
||||
expect(text).toContain(' 3 | return "needle";');
|
||||
});
|
||||
|
||||
it("replaces a chunk using a copied selector in path", async () => {
|
||||
@@ -366,10 +366,9 @@ describe("chunk mode tools", () => {
|
||||
const selector = extractSelector(branchText, HANDLE_ERROR_CHUNK_PATH);
|
||||
|
||||
const editResult = await editTool.execute("chunk-edit", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: selector,
|
||||
path: `${filePath}:${selector}`,
|
||||
op: "replace",
|
||||
content: ` private handleError(err: Error): string {
|
||||
return \`normalized:\${err.message}\`;
|
||||
@@ -377,7 +376,7 @@ describe("chunk mode tools", () => {
|
||||
`,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
const editText = getText(editResult);
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
|
||||
@@ -412,24 +411,23 @@ describe("chunk mode tools", () => {
|
||||
const selector = extractSelector(branchText, "class_Server.fn_handle");
|
||||
|
||||
const editResult = await editTool.execute("chunk-edit-hierarchy", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: selector,
|
||||
path: `${filePath}:${selector}`,
|
||||
op: "replace",
|
||||
content: ' handle(): void {\n console.log("new");\n }\n',
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
const editText = getText(editResult);
|
||||
|
||||
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).toMatch(/^ {3}\|server\.ts·10L·typescript·#/m);
|
||||
expect(editText).toMatch(/^\* {2}\|\[<class_Server#/m);
|
||||
expect(editText).toMatch(/^\* {2}\|\s+\[<class_Server\.fn_handle#/m);
|
||||
expect(editText).toMatch(/^ {3}\|\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("1 |class Server {");
|
||||
expect(editText).not.toContain("crc updated");
|
||||
expect(editText).not.toContain(" lns)");
|
||||
expect(editText).not.toContain("[<class_Server.fn_other#");
|
||||
@@ -445,15 +443,14 @@ describe("chunk mode tools", () => {
|
||||
const checksum = getChunkChecksum(source, "typescript", "class_Server.fn_handle");
|
||||
|
||||
const result = await editTool.execute("chunk-edit-preserve-indent", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `class_Server.fn_handle#${checksum}`,
|
||||
path: `${filePath}:class_Server.fn_handle#${checksum}`,
|
||||
op: "replace",
|
||||
content: ' handle(): void {\n if (flag) {\n\tconsole.log("tabbed");\n }\n }\n',
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
const text = getText(result);
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
|
||||
@@ -488,15 +485,14 @@ describe("chunk mode tools", () => {
|
||||
const checksum2 = getChunkChecksum(afterFirst, "typescript", chunkPath);
|
||||
|
||||
await editTool.execute("chunk-edit-default-selector-batch", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `${chunkPath}#${checksum}`,
|
||||
path: `${filePath}:${chunkPath}#${checksum}`,
|
||||
op: "replace",
|
||||
content: buildHandleErrorMethod({ returnLine: " return err.message.toUpperCase() + total;" }),
|
||||
},
|
||||
{
|
||||
sel: `${chunkPath}#${checksum2}`,
|
||||
path: `${filePath}:${chunkPath}#${checksum2}`,
|
||||
op: "replace",
|
||||
content: buildHandleErrorMethod({
|
||||
totalInitLine: " let total = 1;",
|
||||
@@ -504,7 +500,7 @@ describe("chunk mode tools", () => {
|
||||
}),
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
expect(updatedSource).toContain("let total = 1;");
|
||||
@@ -539,15 +535,14 @@ describe("chunk mode tools", () => {
|
||||
|
||||
const classChecksum = getChunkChecksum(await Bun.file(filePath).text(), "typescript", "class_Server");
|
||||
await editTool.execute("chunk-edit-string-content", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `class_Server#${classChecksum}`,
|
||||
path: `${filePath}:class_Server#${classChecksum}`,
|
||||
op: "after",
|
||||
content: '\nfunction status(): string {\n return "ok";\n}\n',
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
expect(updatedSource).toContain("status(): string");
|
||||
@@ -563,8 +558,7 @@ describe("chunk mode tools", () => {
|
||||
const checksum = getChunkChecksum(originalSource, "typescript", "fn_main");
|
||||
|
||||
await editTool.execute("chunk-edit-empty-replace-delete", {
|
||||
path: filePath,
|
||||
edits: [{ sel: `fn_main#${checksum}`, op: "replace", content: "" }],
|
||||
edits: [{ path: `${filePath}:fn_main#${checksum}`, op: "replace", content: "" }],
|
||||
});
|
||||
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
@@ -596,14 +590,13 @@ describe("chunk mode tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("chunk-edit-batch-rollback", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `class_Server#${getChunkChecksum(originalSource, "typescript", "class_Server")}`,
|
||||
path: `${filePath}:class_Server#${getChunkChecksum(originalSource, "typescript", "class_Server")}`,
|
||||
op: "append",
|
||||
content: ' status(): string {\n return "ok";\n }',
|
||||
},
|
||||
{ sel: `${HANDLE_ERROR_CHUNK_PATH}#ZZZZ`, op: "replace", content: "" },
|
||||
{ path: `${filePath}:${HANDLE_ERROR_CHUNK_PATH}#ZZZZ`, op: "replace", content: "" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/No changes were saved/);
|
||||
@@ -621,10 +614,9 @@ describe("chunk mode tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("chunk-edit-parse-reject", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `${HANDLE_ERROR_CHUNK_PATH}#${checksum}`,
|
||||
path: `${filePath}:${HANDLE_ERROR_CHUNK_PATH}#${checksum}`,
|
||||
op: "replace",
|
||||
content: " private handleError(err: Error): string {\n if (err) {\n",
|
||||
},
|
||||
@@ -644,15 +636,14 @@ describe("chunk mode tools", () => {
|
||||
const checksum = getChunkChecksum(originalSource, "typescript", HANDLE_ERROR_CHUNK_PATH);
|
||||
|
||||
const _result = await editTool.execute("chunk-edit-stale-mixed-batch", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `${HANDLE_ERROR_CHUNK_PATH}#${checksum}`,
|
||||
path: `${filePath}:${HANDLE_ERROR_CHUNK_PATH}#${checksum}`,
|
||||
op: "replace",
|
||||
content: " private handleError(err: Error): string {\n return err.message;\n }",
|
||||
},
|
||||
{
|
||||
sel: `${HANDLE_ERROR_CHUNK_PATH}#${checksum}`,
|
||||
path: `${filePath}:${HANDLE_ERROR_CHUNK_PATH}#${checksum}`,
|
||||
op: "replace",
|
||||
content: " private handleError(err: Error): string {\n return err.message.toUpperCase();\n }",
|
||||
},
|
||||
@@ -672,10 +663,9 @@ describe("chunk mode tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("chunk-edit-strong-crc", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: HANDLE_ERROR_CHUNK_PATH,
|
||||
path: `${filePath}:${HANDLE_ERROR_CHUNK_PATH}`,
|
||||
op: "replace",
|
||||
content: buildHandleErrorMethod({ totalInitLine: " let total = 1;" }),
|
||||
},
|
||||
@@ -695,10 +685,9 @@ describe("chunk mode tools", () => {
|
||||
|
||||
// Use bare "main" instead of "fn_main"
|
||||
const _result = await editTool.execute("chunk-edit-prefix-resolve", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `main#${checksum}`,
|
||||
path: `${filePath}:main#${checksum}`,
|
||||
op: "replace",
|
||||
content: 'function main(): void {\n console.log("started");\n}\n',
|
||||
},
|
||||
@@ -718,15 +707,14 @@ describe("chunk mode tools", () => {
|
||||
const checksum = getChunkChecksum(originalSource, "typescript", HANDLE_ERROR_CHUNK_PATH);
|
||||
|
||||
await editTool.execute("chunk-edit-full-path-resolve", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `class_Server.handleError#${checksum}`,
|
||||
path: `${filePath}:class_Server.handleError#${checksum}`,
|
||||
op: "replace",
|
||||
content: ` private handleError(err: Error): string {\n return \`expanded:\${err.message}\`;\n }\n`,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
expect(updatedSource).toContain("expanded:");
|
||||
@@ -745,15 +733,14 @@ describe("chunk mode tools", () => {
|
||||
const staleChecksum = getChunkChecksum(originalSource, "typescript", "class_A.fn_run");
|
||||
|
||||
await editTool.execute("chunk-edit-stale-child-selector", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `class_A.fn_run_2#${staleChecksum}`,
|
||||
path: `${filePath}:class_A.fn_run_2#${staleChecksum}`,
|
||||
op: "replace",
|
||||
content: "run(): void { patched(); }\n",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
|
||||
const finalSource = await Bun.file(filePath).text();
|
||||
expect(finalSource).toContain("other();");
|
||||
@@ -796,10 +783,9 @@ describe("chunk mode tools", () => {
|
||||
}
|
||||
|
||||
await editTool.execute("chunk-edit-section-replace", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
sel: `${building.path}#${building.checksum}`,
|
||||
path: `${filePath}:${building.path}#${building.checksum}`,
|
||||
op: "replace",
|
||||
content: "## Building and Testing\n\nUse `just verify` instead. It wraps cmake and ctest.\n",
|
||||
},
|
||||
@@ -870,8 +856,7 @@ describe("chunk mode tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("chunk-edit-missing-selector", {
|
||||
path: filePath,
|
||||
edits: [{ sel: "class_Server.fn_missing", op: "before", content: " noop(): void {}" }],
|
||||
edits: [{ path: `${filePath}:class_Server.fn_missing`, op: "before", content: " noop(): void {}" }],
|
||||
}),
|
||||
).rejects.toThrow(/Direct children of "class_Server":\n└── \.fn_handle#[A-Z]{4}\s+L\d+-L\d+/);
|
||||
});
|
||||
@@ -882,8 +867,7 @@ describe("chunk mode tools", () => {
|
||||
|
||||
await expect(
|
||||
editTool.execute("chunk-edit-missing-file", {
|
||||
path: missingPath,
|
||||
edits: [{ sel: "fn_foo", op: "replace", content: "function foo() {}" }],
|
||||
edits: [{ path: `${missingPath}:fn_foo`, op: "replace", content: "function foo() {}" }],
|
||||
}),
|
||||
).rejects.toThrow(/File does not exist.*Cannot resolve chunk selectors/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user