From 83e897d5ea82b930b11ac9d27ddb7a188af79c0e Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Apr 2026 19:56:42 +0200 Subject: [PATCH] fix(pi-natives-chunk): corrected chunk edits to reject invalid ^/~ ops - Fixed chunk edits to reject invalid `^`/`~` operations on code-leaf chunks. - Fixed multiline replacement behavior to preserve literal insertion text and adjust multiline match starts. - Fixed unsupported-region handling to warn, fall back to whole-chunk edits, and clear invalid region targets. - Documented chunk-edit behavior for raw whitespace, parser boundaries, `sel="?"` refresh, markdown spacing, and streaming headers. --- crates/pi-natives/src/chunk/edit.rs | 325 +++++++++++++++--- packages/coding-agent/CHANGELOG.md | 7 +- .../src/prompts/tools/chunk-edit.md | 16 +- .../src/prompts/tools/open-chunk.md | 5 + 4 files changed, 304 insertions(+), 49 deletions(-) diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index b9bf48094..38b7196fe 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -75,6 +75,11 @@ struct ResolvedEditTarget { region: Option, } +enum RegionFallback { + Reject(String), + Warn(String), +} + const NORMALIZED_TAB_REPLACEMENT: &str = " "; const PRESERVED_TAB_REPLACEMENT: &str = "\t"; @@ -606,6 +611,7 @@ fn resolve_edit_target( warnings, )? .clone(); + let requested_region = region; let python_leaf_control_flow = state.language == "python" && chunk.leaf && matches!( @@ -618,17 +624,82 @@ fn resolve_edit_target( | ChunkKind::Elif | ChunkKind::Except ); - if chunk.prologue_end_byte.is_none() - || chunk.epilogue_start_byte.is_none() - || python_leaf_control_flow - || (chunk.kind == ChunkKind::Section && matches!(operation.op, ChunkEditOp::Put)) - { + if let Some(action) = unsupported_region_action(state, &chunk, operation.op, requested_region) { + match action { + RegionFallback::Reject(reason) => return Err(reason), + RegionFallback::Warn(reason) => warnings.push(reason), + } + region = None; + } else if python_leaf_control_flow { region = None; } Ok(ResolvedEditTarget { chunk, region }) } +const fn region_label(region: ChunkRegion) -> &'static str { + match region { + ChunkRegion::Head => "^", + ChunkRegion::Body => "~", + } +} + +fn unsupported_region_action( + state: &ChunkStateInner, + chunk: &ChunkNode, + op: ChunkEditOp, + requested_region: Option, +) -> Option { + let region = requested_region?; + let label = region_label(region); + let chunk_label = chunk_path_opt(chunk); + let warn_on_region_fallback = + matches!( + state.language.as_str(), + "markdown" + | "md" | "handlebars" + | "hbs" | "yaml" + | "yml" | "json" + | "jsonc" + | "toml" | "text" + | "txt" + ); + + let detail = if state.language == "python" + && chunk.leaf + && matches!( + chunk.kind, + ChunkKind::If + | ChunkKind::Loop + | ChunkKind::Try + | ChunkKind::Block + | ChunkKind::Match + | ChunkKind::Elif + | ChunkKind::Except + ) { + Some("Python compound-statement leaf chunks do not expose a safe body/head edit boundary") + } else if chunk.prologue_end_byte.is_none() || chunk.epilogue_start_byte.is_none() { + Some("this chunk has no body/head edit boundary") + } else if chunk.kind == ChunkKind::Section && matches!(op, ChunkEditOp::Put) { + Some("section writes do not expose a safe body/head edit boundary") + } else { + None + }?; + + if warn_on_region_fallback { + return Some(RegionFallback::Warn(format!( + "Region suffix `{label}` on {chunk_label} fell back to whole-chunk editing because \ + {detail}. Include the complete chunk content, including headings, fences, list markers, \ + or table rows." + ))); + } + + Some(RegionFallback::Reject(format!( + "Region suffix `{label}` is not supported on {chunk_label}: {detail}. Use the unsuffixed \ + selector with complete replacement content, or edit a parent container's `~` body instead." + ))) +} + fn find_current_batch_target_after_edit<'a>( state: &'a ChunkStateInner, before: &ChunkNode, @@ -690,6 +761,10 @@ fn update_current_batch_target( /// indentation. Detects the base indent of the first line in `original` and /// applies it to `replacement`. fn reindent_replacement(original: &str, replacement: &str) -> String { + if replacement.contains('\n') { + return replacement.to_string(); + } + let orig_indent = original .lines() .next() @@ -720,6 +795,36 @@ fn reindent_replacement(original: &str, replacement: &str) -> String { .join("\n") } +fn expanded_match_start_for_multiline_replacement( + source: &str, + abs_start: usize, + replacement: &str, +) -> Option { + if !replacement.contains('\n') { + return None; + } + + let line_start = source[..abs_start].rfind('\n').map_or(0, |idx| idx + 1); + if line_start == abs_start { + return None; + } + + let existing_prefix = &source[line_start..abs_start]; + if existing_prefix.is_empty() + || !existing_prefix + .bytes() + .all(|byte| matches!(byte, b' ' | b'\t')) + { + return None; + } + + if replacement.starts_with(existing_prefix) { + Some(line_start) + } else { + None + } +} + /// Try to find `needle` in `haystack` by normalizing leading whitespace on each /// line. Returns `(byte_offset, byte_length)` of the match in `haystack`. fn find_indent_normalized(haystack: &str, needle: &str) -> Option<(usize, usize)> { @@ -822,8 +927,13 @@ fn apply_find_replace( }; let raw_replacement = operation.content.as_deref().unwrap_or_default(); - let abs_start = region_start + rel_offset; + let mut abs_start = region_start + rel_offset; let abs_end = abs_start + match_len; + if let Some(expanded_start) = + expanded_match_start_for_multiline_replacement(&state.source, abs_start, raw_replacement) + { + abs_start = expanded_start; + } // Re-indent replacement to match the matched source's indentation when // indent normalization is active. @@ -2125,8 +2235,13 @@ fn normalize_insertion_boundary_content( } else { usize::from(prev_char.is_some() && prev_char != Some('\n')) }; + let leading_newlines_after = count_leading_newlines_after_offset(&state.source, offset); let suffix_newlines = if spacing.blank_line_after { - 2usize.saturating_sub(count_leading_newlines_after_offset(&state.source, offset)) + 2usize.saturating_sub(leading_newlines_after) + } else if leading_newlines_after == 1 && content.ends_with('\n') { + // Preserve an existing blank-line separator when appending line-oriented + // content immediately before the separator's single newline. + 1 } else { usize::from(next_char.is_some() && next_char != Some('\n')) }; @@ -3492,6 +3607,36 @@ function helper(): void { ); } + #[test] + fn find_replace_preserves_multiline_replacement_indentation() { + let source = "enum Status {\n\tRunning,\n\tStopped,\n}\n"; + let state = state_for(source, "rust"); + let chunk = state.inner().chunk("en_Sta").expect("en_Sta"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("en_Sta".to_owned()), + crc: Some(chunk.checksum.clone()), + region: None, + content: Some("\tPaused,\n\tStopped,\n\tFailed,".to_owned()), + find: Some("Stopped,".to_owned()), + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + normalize_indent: None, + }) + .expect("edit should apply"); + + assert_eq!( + result.diff_after, + "enum Status {\n\tRunning,\n\tPaused,\n\tStopped,\n\tFailed,\n}\n" + ); + } + #[test] fn focus_emits_only_changed_chain() { let source = "const a = 1;\n\nconst b = 2;\n\nconst c = 3;\n\nconst d = 4;\n\nconst e = 5;\n"; @@ -4247,6 +4392,108 @@ function helper(): void { ); } + #[test] + fn markdown_body_write_region_fallback_warns_before_whole_chunk_replace() { + let source = "# Title\n\n## Alpha\n\nalpha body\n\n## Beta\n\nbeta body\n"; + let state = state_for(source, "markdown"); + let section = state + .inner() + .chunk("sct_Tit.sct_Alp") + .expect("alpha section"); + + let result = apply_single_edit(&state, "test.md", EditOperation { + op: ChunkEditOp::Put, + sel: Some(format!("{}#{}~", section.path, section.checksum)), + crc: None, + region: None, + content: Some("## Alpha\n\nnew body\n".to_owned()), + find: None, + }); + + assert!( + result + .diff_after + .contains("## Alpha\n\nnew body\n\n## Beta"), + "whole-section replacement should preserve the next heading separator: {:?}", + result.diff_after + ); + assert!( + result + .warnings + .iter() + .any(|warning| warning.contains("fell back to whole-chunk editing")), + "markdown region fallback should warn: {:?}", + result.warnings + ); + } + + #[test] + fn markdown_table_body_append_keeps_row_continuity() { + let source = "## Section\n\n| A |\n| --- |\n| one |\n\n## Next\n"; + let state = state_for(source, "markdown"); + let table = state + .inner() + .tree + .chunks + .iter() + .find(|chunk| chunk.start_line == 3 && chunk.end_line == 5) + .expect("table chunk"); + + let result = apply_single_edit(&state, "test.md", EditOperation { + op: ChunkEditOp::Append, + sel: Some(format!("{}#{}~", table.path, table.checksum)), + crc: None, + region: None, + content: Some("| two |\n".to_owned()), + find: None, + }); + + assert!( + result.diff_after.contains("| one |\n| two |\n\n## Next"), + "appended table row should stay contiguous with the table and preserve next-section \ + spacing: {:?}", + result.diff_after + ); + assert!( + result + .warnings + .iter() + .any(|warning| warning.contains("fell back to whole-chunk editing")), + "table body-region append fallback should warn: {:?}", + result.warnings + ); + } + + #[test] + fn markdown_fenced_python_body_write_preserves_code_indent() { + let source = "```python\ndef outer():\n if cond:\n return 1\n```\n"; + let state = state_for(source, "markdown"); + let function = state + .inner() + .tree + .chunks + .iter() + .find(|chunk| chunk.kind == ChunkKind::Function) + .expect("embedded python function chunk"); + + let result = apply_single_edit(&state, "test.md", EditOperation { + op: ChunkEditOp::Put, + sel: Some(format!("{}#{}~", function.path, function.checksum)), + crc: None, + region: None, + content: Some("if cond:\n\treturn 2\nreturn 3\n".to_owned()), + find: None, + }); + + assert!( + result + .diff_after + .contains("def outer():\n if cond:\n return 2\n return 3\n```"), + "embedded fenced Python body should keep 4-space code indentation: {:?}", + result.diff_after + ); + } + #[test] fn rust_trait_members_are_addressable() { let source = "trait Handler {\n fn handle(&self, req: &str) -> String;\n fn \ @@ -4296,7 +4543,7 @@ function helper(): void { } #[test] - fn body_region_on_leaf_without_delimiters_falls_back_to_full_chunk() { + fn body_region_on_leaf_without_delimiters_is_rejected() { let source = "enum LogLevel {\n Debug,\n Info,\n Warn,\n Fatal,\n}\n"; let state = state_for(source, "rust"); let chunk = state @@ -4307,7 +4554,7 @@ function helper(): void { for region_suffix in ["~", "^"] { let sel = format!("en_Log.vr_Inf#{}{}", chunk.checksum, region_suffix); - let result = apply_edits(&state, &EditParams { + let err = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Put, sel: Some(sel), @@ -4323,12 +4570,16 @@ function helper(): void { file_path: "test.rs".to_owned(), normalize_indent: None, }) - .expect("leaf region should fall back to full chunk"); + .err() + .expect("leaf region should be rejected"); assert!( - result.diff_after.contains("Debug,\n Error,\n Warn,"), - "{region_suffix} should replace the full leaf chunk, got: {}", - result.diff_after + err.contains("Region suffix"), + "{region_suffix} should return a clear region error, got: {err}" + ); + assert!( + err.contains("unsuffixed selector"), + "{region_suffix} error should mention the safe workaround, got: {err}" ); } } @@ -5266,10 +5517,11 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> } #[test] - fn body_region_on_leaf_if_falls_back_to_whole_chunk_python() { - // Python `if` inside a function body is a leaf chunk with prologue/epilogue - // bytes set by the classifier. Using `~` on it should fall back to - // whole-chunk replacement instead of mangling the guard. + fn body_region_on_leaf_if_is_rejected_python() { + // Python `if` inside a function body is a leaf chunk. Using `~` on it + // used to fall back to a whole-chunk replacement and could corrupt + // indentation around the guard; it is now rejected with a clear + // workaround instead. let source = "def handle(request):\n x = 1\n y = 2\n if request.ok:\n \ return \"yes\"\n z = 3\n for item in items:\n process(item)\n \ return \"no\"\n"; @@ -5287,7 +5539,7 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> .expect("if chunk should exist"); assert!(if_chunk.leaf, "if chunk should be leaf"); - let result = apply_edits(&state, &EditParams { + let err = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Put, sel: Some(format!("{}~", if_chunk.path)), @@ -5303,23 +5555,21 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> file_path: "test.py".to_owned(), normalize_indent: None, }) - .expect("leaf ~ should fall back to whole-chunk, not produce a parse error"); + .err() + .expect("leaf ~ should be rejected"); - assert!(result.parse_valid, "edit should produce valid Python"); assert!( - result.diff_after.contains("return \"forced\""), - "replacement content should appear in output, got: {}", - result.diff_after + err.contains("Python compound-statement leaf chunks"), + "error should identify the unsafe leaf fallback, got: {err}" ); assert!( - result.diff_after.contains("if request.ok:"), - "guard should be preserved (whole-chunk replacement), got: {}", - result.diff_after + err.contains("parent container's `~`"), + "error should mention the parent-body workaround, got: {err}" ); } #[test] - fn body_region_fallback_preserves_head_when_replacement_omits_it_python() { + fn body_region_fallback_that_omits_python_head_is_rejected() { let source = "def handle(request):\n x = 1\n y = 2\n if request.ok:\n \ return \"yes\"\n z = 3\n for item in items:\n process(item)\n \ return \"no\"\n"; @@ -5332,7 +5582,7 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> .find(|c| c.kind == ChunkKind::If) .expect("if chunk should exist"); - let result = apply_edits(&state, &EditParams { + let err = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Put, sel: Some(format!("{}~", if_chunk.path)), @@ -5348,23 +5598,12 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> file_path: "test.py".to_owned(), normalize_indent: None, }) - .expect("fallback body edit should preserve head and stay parse-valid"); + .err() + .expect("fallback body edit should be rejected"); - assert!(result.parse_valid, "edit should remain parse-valid"); assert!( - result.diff_after.contains("if request.ok:"), - "if guard should be preserved, got: {}", - result.diff_after - ); - assert!( - result.diff_after.contains("return \"forced\""), - "replacement body should appear, got: {}", - result.diff_after - ); - assert!( - !result.diff_after.contains("return \"yes\""), - "old body should be replaced, got: {}", - result.diff_after + err.contains("Use the unsuffixed selector with complete replacement content"), + "error should tell the operator to include the complete leaf chunk, got: {err}" ); } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dceca5439..fd24cab74 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Changed the canonical file/URL reader tool from `read` to `open` across default tool lists and routing, including system prompts, plan mode, cursor handlers, and runtime tool registration - Changed runtime and UI handling to render and track `open` tool calls as first-class (with `read` accepted as legacy alias), including ACP mapping, session observers, and streaming message groups +- Changed chunk edit guidance to document parser-specific region behavior, including TypeScript decorator/JSDoc sibling chunks, Python docstrings as body content, Python opaque nested chunks, Markdown whole-chunk fallbacks, ID volatility, and indentation display differences - Changed fetch output logging so URL-fetch artifacts now use `.open.log` naming instead of `.read.log` - Changed Bash interception guidance and errors to recommend `open` in place of `read` for cat/head/tail-style commands - Changed exported SDK tool surface to expose `OpenTool` as canonical and keep `ReadTool` as a compatibility alias @@ -29,6 +30,10 @@ - Fixed bash command minimization to save the full unminimized output as a `bash-original` artifact during AgentSession shell execution, enabling `artifact://` access to complete command output - Fixed streaming chunk previews that could display an incomplete trailing edit as a deletion when partial JSON temporarily converted in-flight values to `null` - Fixed edit streaming preview updates to cancel obsolete in-flight computations and avoid rendering stale previews as args change +- Fixed chunk edits to reject unsafe `^`/`~` writes on code leaf chunks instead of falling back to whole-chunk replacement and risking structural indentation corruption +- Fixed chunk `replace` operations to preserve multiline replacement indentation literally instead of stripping leading whitespace from inserted lines +- Fixed Markdown chunk appends to preserve blank-line separators after line-oriented inserts such as table rows +- Fixed streaming edit call headers to keep showing the target file path while the edit arguments are still arriving - Fixed Mermaid fenced markdown rendering in assistant messages on terminals without image protocol support ([#650](https://github.com/can1357/oh-my-pi/issues/650)) - Fixed chunk edit path parsing so plan-mode edits to section-addressed `local://PLAN.md:` paths are classified as writes to the plan file - Fixed SQLite `read` helper queries to reject `where=` clauses with SQL control syntax that could override the structured selector's pagination; raw SQL remains available through `q=SELECT ...` @@ -7158,4 +7163,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index 2d7c90e41..95e174efa 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -16,6 +16,7 @@ Call format: `{"edits": [{"path": "file:chunk#ID~", "write": "new body"}, …]}` ``` The tool adds the correct base indent automatically. Never manually pad with the chunk's own indentation. Multiple sibling body lines at the same level all start at column 0: `"print(a)\nprint(b)\nprint(c)\n"`. Only use `\t` when nesting deeper (e.g. `"if cond:\n\tinner\nouter\n"`). + Before applying the target's base indent, the tool strips any common leading whitespace shared by all non-empty `write` lines as a safety net. Do not rely on that cleanup for mixed indentation; write `~` bodies at column 0 and use one `\t` per relative nesting level. **Common mistake** when replacing `~` of a function body: do NOT include the function's own indentation. Wrong: `"if b == 0:\n\t\treturn None\n\treturn a / b\n"` — adds the function's base `\t` to every line. Correct: `"if b == 0:\n\treturn None\nreturn a / b\n"` — `if` and `return a / b` at column 0, only `return None` gets `\t` for nesting. @@ -26,8 +27,10 @@ Call format: `{"edits": [{"path": "file:chunk#ID~", "write": "new body"}, …]}` content: "if (x) {\n return true;\n}" ``` The tool adds the correct base indent automatically, then preserves the tabs/spaces you used inside the snippet. Never manually pad with the chunk's own indentation. + Before applying the target's base indent, the tool strips any common leading whitespace shared by all non-empty `write` lines as a safety net. Do not rely on that cleanup for mixed indentation; write `~` bodies at column 0. {{/if}} -- Region suffixes only apply to container chunks (classes, functions, impl blocks, sections). On leaf chunks (enum variants, fields, single statements, and compound statements like `if`/`for`/`while`/`match`/`try`), `~` and `^` silently fall back to whole-chunk replacement — prefer the unsuffixed form and always supply the complete replacement (condition + body, not just the body) to avoid dropping structural parts. +- Region suffixes only apply to chunks with a real head/body boundary (classes, functions, impl blocks, and similar containers). On code leaf chunks (enum variants, fields, single statements, and compound statements like `if`/`for`/`while`/`match`/`try`), `~` and `^` are rejected. Use the unsuffixed selector and supply the complete replacement content, or edit the parent container's `~` body. +- `replace: {old, new}` treats multiline `new` text literally. If `new` contains a newline, include the exact leading whitespace required on every inserted line; it is not stripped and re-indented like `write` content. If `old` starts after existing line indentation and multiline `new` includes that same indentation on its first line, the existing prefix is consumed to avoid double-indenting the first inserted line. - `put`, `find`+`replace`, and `delete` require the current ID. `prepend`/`append` do not. - **IDs change after every edit.** The edit response always carries the new IDs — use those for the next call or run `open(path="file", sel="?")` to refresh. Never reuse an ID from before the latest edit. @@ -42,14 +45,16 @@ You **MUST** use the narrowest region that covers your change. Putting without a 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: -- `fn_foo#ID~` — **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#ID~` — **body only (the default choice for most edits).** Head lines (`^`) are preserved automatically — doc comments, attributes, and signature stay untouched. On code leaf chunks, this is rejected because there is no safe body boundary. - `fn_foo#ID^` — head only (decorators, attributes, doc comments, signature, opening delimiter). Body stays untouched. - `fn_foo#ID` — 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#ID` 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#ID` directly above your target. +**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#ID` in the `?` listing) rather than as part of the function's `^`. JSDoc directly above a plain function is more likely to be absorbed into that function's `^`. If you need to rewrite a decorated member, run `open(path="file", sel="?")` and check for a sibling `chunk#ID` directly above your target. -**Note on non-code formats:** for prose and data formats (markdown, YAML, JSON, fenced code blocks, frontmatter), `^` and `~` fall back to the whole chunk. Always replace the entire chunk and include any delimiter syntax (fence backticks, `---` frontmatter markers, list markers) in your `content` — omitting them deletes them. For markdown sections (`sect_*`), always use unsuffixed whole-chunk replace — `^` and `~` on section containers also fall back to whole-chunk replace. When editing fenced code blocks in markdown, use the exact whitespace from the file (read with `raw` first) — the tool preserves literal indentation inside fenced blocks, but any content you supply is written verbatim. To insert content after a markdown section heading, use `after` on the heading chunk (`sect_*.chunk` or `sect_*.chunk_1`) — not `before`/`prepend` on the section itself, which lands physically before the heading and gets absorbed by the preceding section on reparse. +**Python notes:** Python docstrings are body lines, not head lines. A `~` body write on a function that has a docstring deletes the docstring unless you include the docstring in `content`. Python enum members and nested functions/closures are often opaque inside their parent chunk and may not appear as addressable child chunks; use `replace` on the parent chunk or rewrite the parent container body. + +**Note on non-code formats:** for prose and data formats (markdown, YAML, JSON, frontmatter), unsupported `^` and `~` suffixes warn and fall back to whole-chunk editing. Always replace the entire chunk and include any delimiter syntax (fence backticks, `---` frontmatter markers, list markers, table rows, headings) in your `content` — omitting them deletes them. For markdown sections (`sect_*`), prefer unsuffixed whole-chunk replace because `^`/`~` on prose sections can replace the heading too. Fenced code blocks are the exception when the embedded language parser exposes inner chunks; otherwise read with `raw` first and preserve the exact whitespace inside fences. Be cautious appending to markdown tables or lists: if spacing is delicate, rewrite the whole table/list chunk so blank lines and row continuity stay under your control. To insert content after a markdown section heading, use `after` on the heading chunk (`sect_*.chunk` or `sect_*.chunk_1`) — not `before`/`prepend` on the section itself, which lands physically before the heading and gets absorbed by the preceding section on reparse. @@ -268,6 +273,7 @@ Result — the method (including its doc comment and signature) is removed. - Match the file's real indentation characters in your snippet. The tool preserves your literal tabs/spaces after adding the target region's base indent. {{/if}} - Do NOT include the chunk's base indentation — only indent relative to the region's opening level. + - For `write`, the tool strips common leading whitespace shared by all non-empty lines, then adds the target region's base indent. If lines have mixed relative indentation, write them at column 0 so the common-margin cleanup cannot change the structure. - For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Multiple sibling lines: `"print(a)\nprint(b)\nprint(c)\n"` — all at column 0, the tool adds the function's base indent. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in. Python example — to replace `~` of `def divide(a, b):`, write: `"if b == 0:\n\treturn None\nreturn a / b\n"` — the `if` and `return a / b` are at column 0, `return None` is one `\t` in. - For `^`: write at the chunk's own depth. A class member's head uses `"/// doc\n#[attr]\npub fn start() {"`. {{#if chunkAutoIndent}} @@ -275,5 +281,5 @@ Result — the method (including its doc comment and signature) is removed. {{else}} - 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. + - `replace: {old, new}` does not use the same multiline reindent model. Single-line `new` text inherits the matched line's indentation. Multiline `new` text is inserted literally, so include the exact leading whitespace for every inserted line; when the first replacement line repeats the matched line's existing indentation prefix, the tool consumes that prefix to prevent double indentation. diff --git a/packages/coding-agent/src/prompts/tools/open-chunk.md b/packages/coding-agent/src/prompts/tools/open-chunk.md index ad68955f1..c5b409595 100644 --- a/packages/coding-agent/src/prompts/tools/open-chunk.md +++ b/packages/coding-agent/src/prompts/tools/open-chunk.md @@ -36,6 +36,11 @@ Chunk reads normalize leading indentation so copied content round-trips cleanly {{else}} Chunk reads preserve literal leading tabs/spaces from the file. When editing, keep the same whitespace characters you see here. {{/if}} +`raw` shows the file's literal whitespace. Structured chunk views may normalize or display indentation for edit round-tripping, so use `raw` when exact tabs/spaces matter, especially inside markdown fenced code blocks. + +IDs change after every edit. Use the new IDs from the edit response or refresh with `sel="?"` before the next write. + +Parser boundaries vary by language: TypeScript/JavaScript decorators and JSDoc above decorated methods may appear as sibling `chunk#ID` entries, Python docstrings are body lines, and Python enum members or nested closures may remain opaque inside their parent chunk. Chunk trees: JS, TS, TSX, Python, Rust, Go. Others use blank-line fallback. # Inspection