diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7a3843137..f40dbbe1a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Auto-correction of escaped tab indentation in edits (enabled by default, controllable via `PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS` environment variable) +- Warning when suspicious Unicode escape placeholder `\uDDDD` is detected in edit content + +### Changed + +- Updated hashline documentation to clarify that `\t` in JSON represents a real tab character, not a literal backslash-t sequence ## [13.5.2] - 2026-03-01 diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 89ed7d576..58b736d54 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -411,6 +411,45 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: } } +function isEscapedTabAutocorrectEnabled(): boolean { + const value = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + if (value === "0") return false; + if (value === "1") return true; + return true; +} + +function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void { + if (!isEscapedTabAutocorrectEnabled()) return; + for (const edit of edits) { + if (edit.lines.length === 0) continue; + const hasEscapedTabs = edit.lines.some(line => line.includes("\\t")); + if (!hasEscapedTabs) continue; + const hasRealTabs = edit.lines.some(line => line.includes("\t")); + if (hasRealTabs) continue; + let correctedCount = 0; + const corrected = edit.lines.map(line => + line.replace(/^((?:\\t)+)/, escaped => { + correctedCount += escaped.length / 2; + return "\t".repeat(escaped.length / 2); + }), + ); + if (correctedCount === 0) continue; + edit.lines = corrected; + warnings.push( + `Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`, + ); + } +} + +function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void { + for (const edit of edits) { + if (edit.lines.length === 0) continue; + if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue; + warnings.push( + `Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`, + ); + } +} // ═══════════════════════════════════════════════════════════════════════════ // Edit Application // ═══════════════════════════════════════════════════════════════════════════ @@ -493,6 +532,8 @@ export function applyHashlineEdits( if (mismatches.length > 0) { throw new HashlineMismatchError(mismatches, fileLines); } + maybeAutocorrectEscapedTabIndentation(edits, warnings); + maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings); // Deduplicate identical edits targeting the same line(s) const seenEditKeys = new Map(); const dedupIndices = new Set(); diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 424132420..e6a12a99c 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -249,15 +249,15 @@ Good — anchors to structural line: -Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. Check the actual indent of neighboring lines. +Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. If the file uses tabs, use `\t` in JSON — you **MUST NOT** use `\\t`, which produces a literal backslash-t in the file. ```ts {{hlinefull 10 "class Foo {"}} -{{hlinefull 11 " bar() {"}} -{{hlinefull 12 " return 1;"}} -{{hlinefull 13 " }"}} -{{hlinefull 14 "}"}} +{{hlinefull 11 "\tbar() {"}} +{{hlinefull 12 "\t\treturn 1;"}} +{{hlinefull 13 "\t}"}} +{{hlinefull 14 "}}"}} ``` -Bad — indent guessed as 4 spaces instead of 2 (as seen on lines 11–13): +Bad — indent guessed as spaces; `\\t` emits literal backslash-t: ``` { path: "…", @@ -272,7 +272,7 @@ Bad — indent guessed as 4 spaces instead of 2 (as seen on lines 11–13): }] } ``` -Good — indent matches the 2-space style visible on adjacent lines: +Good — `\t` in JSON is a real tab, matching the file's indentation: ``` { path: "…", @@ -280,9 +280,9 @@ Good — indent matches the 2-space style visible on adjacent lines: op: "prepend", pos: {{hlinejsonref 14 "}"}}, lines: [ - " baz() {", - " return 2;", - " }" + "\tbaz() {", + "\t\treturn 2;", + "\t}" ] }] } @@ -294,5 +294,5 @@ Good — indent matches the 2-space style visible on adjacent lines: - Every tag **MUST** be copied exactly from fresh tool result as `N#ID`. - You **MUST** re-read after each edit call before issuing another on same file. - Formatting is a batch operation. You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead. If the only change is whitespace, it is formatting; do not touch it. -- `lines` entries **MUST** be literal file content with real space indentation. (`\\t` in JSON inserts a literal backslash-t into the file, not a tab.) +- `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use `\t` in JSON (a real tab character) — you **MUST NOT** use `\\t` (two characters: backslash + t), which produces the literal string `\t` in the file. \ No newline at end of file diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 6f95d0b89..2aee1fec3 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -570,6 +570,65 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.lines).toBe("ALPHA\n\n\ngamma"); expect(result.warnings).toBeUndefined(); }); + it("auto-corrects leading escaped tab indentation by default", () => { + const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + try { + const content = "root\n\tchild\n\t\tvalue\nend"; + const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }]; + const result = applyHashlineEdits(content, edits); + expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings?.[0]).toContain("Auto-corrected escaped tab indentation"); + } finally { + if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous; + } + }); + + it("does not auto-correct escaped tab indentation when disabled by env", () => { + const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0"; + try { + const content = "root\n\tchild\n\t\tvalue\nend"; + const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }]; + const result = applyHashlineEdits(content, edits); + expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend"); + expect(result.warnings).toBeUndefined(); + } finally { + if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous; + } + }); + + it("does not auto-correct when edit already includes real tab characters", () => { + const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + try { + const content = "root\n\tchild\n\t\tvalue\nend"; + const edits: HashlineEdit[] = [ + { + op: "replace", + pos: makeTag(3, "\t\tvalue"), + lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"], + }, + ]; + const result = applyHashlineEdits(content, edits); + expect(result.lines).toBe("root\n\tchild\n\t\talready-tab\n\\t\\tescaped-still-literal\nend"); + expect(result.warnings).toBeUndefined(); + } finally { + if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; + else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous; + } + }); + it("warns on literal \\uDDDD without changing content", () => { + const content = "aaa\nbbb\nccc"; + const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }]; + const result = applyHashlineEdits(content, edits); + expect(result.lines).toBe("aaa\n\\uDDDD\nccc"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings?.[0]).toContain("Detected literal \\uDDDD"); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 150a51635..99bd68a74 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -99,7 +99,6 @@ describe("wrapTextWithAnsi", () => { }); }); - describe("strikethrough styling", () => { it("disables strikethrough at wrapped line ends while preserving fg/bg colors", () => { const strikeOn = "\x1b[9m";