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:
can1357
2026-04-13 06:59:01 +02:00
parent 11b7ba7846
commit 655a3bffea
21 changed files with 844 additions and 621 deletions
+3
View File
@@ -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),
}
+3 -3
View File
@@ -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
+42 -3
View File
@@ -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
);
+46 -49
View File
@@ -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() {
+2 -25
View File
@@ -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, "")
}
+11
View File
@@ -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
+192 -85
View File
@@ -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];
+26 -11
View File
@@ -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);
+16 -10
View File
@@ -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,
+161 -79
View File
@@ -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>
+1 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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);
+26 -56
View File
@@ -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/);
});