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.
This commit is contained in:
can1357
2026-04-24 19:56:42 +02:00
parent ead830283e
commit 83e897d5ea
4 changed files with 304 additions and 49 deletions
+282 -43
View File
@@ -75,6 +75,11 @@ struct ResolvedEditTarget {
region: Option<ChunkRegion>,
}
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<ChunkRegion>,
) -> Option<RegionFallback> {
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<usize> {
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}"
);
}
+6 -1
View File
@@ -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:<selector>` 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
- HTML export with syntax highlighting and collapsible sections
@@ -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.
</rules>
@@ -42,14 +45,16 @@ You **MUST** use the narrowest region that covers your change. Putting without a
<regions>
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.
</regions>
<ops>
@@ -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.
</examples>
@@ -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