@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/package.json +8 -8
- package/src/config/prompt-templates.ts +6 -3
- package/src/edit/block.ts +308 -0
- package/src/edit/indent.ts +150 -0
- package/src/edit/modes/atom.ts +341 -114
- package/src/lsp/utils.ts +6 -36
- package/src/modes/components/status-line.ts +36 -0
- package/src/modes/controllers/event-controller.ts +27 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/atom.md +64 -86
- package/src/prompts/tools/read.md +1 -1
- package/src/session/agent-session.ts +28 -1
- package/src/tools/ast-edit.ts +23 -44
- package/src/tools/ast-grep.ts +18 -42
- package/src/tools/checkpoint.ts +2 -0
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/grep.ts +11 -46
- package/src/tools/grouped-file-output.ts +96 -0
- package/src/tools/read.ts +6 -0
- package/src/tools/report-tool-issue.ts +1 -0
- package/src/tools/resolve.ts +2 -0
- package/src/tools/review.ts +1 -0
- package/src/tools/todo-write.ts +1 -1
- package/src/tools/yield.ts +1 -0
- package/src/utils/tool-choice.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.5.3] - 2026-04-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added bracketed `loc` forms `(anchor)`, `[anchor]`, `[anchor`, `(anchor`, `anchor]`, and `anchor)` to `atom` `splice` editing so a single anchor can target a block body, whole node, or partial node region
|
|
10
|
+
- Added automatic block-delimiter inference for block splices using file extension, defaulting to `{` and using `(` for Lisp-family files
|
|
11
|
+
- Added optional `pre`/`post` arguments to the `href` prompt helper so hashline references can be wrapped as bracketed or parenthesized anchors
|
|
12
|
+
- Added destination-aware indent handling for block replacements by detecting file indent style and reapplying tabs/spaces to spliced body text
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Changed bracketed atom locators to be `splice`-only and reject `pre`, `post`, or `sed` on region locators
|
|
17
|
+
- Changed `applyAtomEdits` to forbid mixing `splice_block` with other anchor-scoped edit verbs in one call
|
|
18
|
+
- Changed `splice_block` resolution behavior to include selected block range and enclosing-count context in warning output
|
|
19
|
+
- Changed balanced-block parsing to support `kind` selection (`{`, `(`, `[`), nesting depth, and safer same-line enclosing selection
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- Removed the `sed` `F` option for literal matching; `sed` now accepts only `pat`, `rep`, and optional `g`, with `F`-style literal matching no longer supported
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed `splice_block` multi-line replacements to replace the exact target region and avoid duplicate braces or duplicated signature lines from bare-anchor `splice` attempts
|
|
28
|
+
- Fixed false-positive “unbalanced” replacement-body warnings caused by braces in regex/string/comment text by skipping those constructs during block scanning
|
|
29
|
+
- Fixed `splice_block` for same-line `(` bodies so inline call sites like `int(port)` can be replaced correctly
|
|
30
|
+
|
|
31
|
+
## [14.5.2] - 2026-04-26
|
|
32
|
+
### Breaking Changes
|
|
33
|
+
|
|
34
|
+
- Removed support for sed-style string expressions and required `sed` to be specified as an object with `pat` and `rep` (and optional `g`, `F`, `i` flags)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- Changed atom `sed` replacements to be global by default and require `g:false` for first-match-only replacements
|
|
39
|
+
- Changed anchor validation so multiple `sed` operations can target the same line and run sequentially
|
|
40
|
+
- Changed cross-entry conflict resolution so `del` edits on an anchor are ignored when that line is also replaced by `sed` or `splice` in another edit entry
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Fixed zero-length regex `sed` patterns (for example `()`, `^`, `$`) to fall back to literal substring matching instead of producing insertion-like replacements
|
|
45
|
+
- Fixed `sed` chaining so each edit on the same anchor applies to the latest line state from prior replacements
|
|
46
|
+
|
|
5
47
|
## [14.5.1] - 2026-04-26
|
|
6
48
|
|
|
7
49
|
### Removed
|
|
@@ -762,6 +804,7 @@
|
|
|
762
804
|
- Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata
|
|
763
805
|
- Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits
|
|
764
806
|
- Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen
|
|
807
|
+
- Fixed status line dropping git branch name when path is long by shrinking the path segment before dropping other segments
|
|
765
808
|
|
|
766
809
|
## [13.18.0] - 2026-04-02
|
|
767
810
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.5.
|
|
4
|
+
"version": "14.5.3",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.20.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.5.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.5.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.5.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.5.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.5.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.5.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.5.3",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.5.3",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.5.3",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.5.3",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.5.3",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.5.3",
|
|
55
55
|
"@puppeteer/browsers": "^2.13.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34.49",
|
|
57
57
|
"@xterm/headless": "^6.0.0",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"zod": "4.3.6"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
|
-
"@types/bun": "^1.3
|
|
72
|
+
"@types/bun": "^1.3",
|
|
73
73
|
"@types/turndown": "5.0.6"
|
|
74
74
|
},
|
|
75
75
|
"engines": {
|
|
@@ -45,11 +45,14 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* {{href lineNum "content"}} — compute a real hashline ref for prompt examples.
|
|
48
|
-
*
|
|
48
|
+
* {{href lineNum "content" "[" "]"}} — wrap the ref with pre/post chars (still quoted).
|
|
49
|
+
* Returns `"lineNumBIGRAM"` (e.g., `"42nd"`), or `"[42nd]"` when pre/post are supplied.
|
|
49
50
|
*/
|
|
50
|
-
prompt.registerHelper("href", (lineNum: unknown, content: unknown): string => {
|
|
51
|
+
prompt.registerHelper("href", (lineNum: unknown, content: unknown, pre?: unknown, post?: unknown): string => {
|
|
51
52
|
const { ref } = formatHashlineRef(lineNum, content);
|
|
52
|
-
|
|
53
|
+
const preStr = typeof pre === "string" ? pre : "";
|
|
54
|
+
const postStr = typeof post === "string" ? post : "";
|
|
55
|
+
return JSON.stringify(`${preStr}${ref}${postStr}`);
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
/**
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-balanced delimiter finder used by the `splice_block` verb.
|
|
3
|
+
*
|
|
4
|
+
* Tokenizes source text to skip strings and comments, then walks a stack of
|
|
5
|
+
* open delimiters to identify the enclosing balanced block for a target line.
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally language-agnostic over the C-family (C, C++, Rust,
|
|
8
|
+
* Go, Java, JS/TS, C#, Swift, Kotlin, Scala, …): it understands `// line`,
|
|
9
|
+
* `/* block * /` comments, double-quoted, single-quoted, and backtick strings
|
|
10
|
+
* with backslash escapes. It does NOT attempt to parse raw string literals,
|
|
11
|
+
* Python triple-quoted strings, or YAML/Python indent-significant blocks —
|
|
12
|
+
* those are out of scope for v1.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type DelimiterKind = "{" | "(" | "[";
|
|
16
|
+
|
|
17
|
+
export interface BlockRange {
|
|
18
|
+
/** Byte/character offset of the opening delimiter. */
|
|
19
|
+
openOffset: number;
|
|
20
|
+
/** Byte/character offset just after the closing delimiter. */
|
|
21
|
+
closeOffsetExclusive: number;
|
|
22
|
+
/** Offset of first character after the opener (start of body). */
|
|
23
|
+
bodyStart: number;
|
|
24
|
+
/** Offset of the closing delimiter character. */
|
|
25
|
+
bodyEnd: number;
|
|
26
|
+
/** 1-indexed line number of the opener. */
|
|
27
|
+
openLine: number;
|
|
28
|
+
/** Byte/character offset of the opener line start. */
|
|
29
|
+
openLineStart: number;
|
|
30
|
+
/** 1-indexed line number of the closer. */
|
|
31
|
+
closeLine: number;
|
|
32
|
+
/** True when opener and closer are on the same line. */
|
|
33
|
+
sameLine: boolean;
|
|
34
|
+
/** Whitespace prefix of the opener's line. */
|
|
35
|
+
openerLineIndent: string;
|
|
36
|
+
/**
|
|
37
|
+
* Whitespace prefix of the first non-blank body line, or `null` when the
|
|
38
|
+
* body has no non-blank line.
|
|
39
|
+
*/
|
|
40
|
+
bodyLineIndent: string | null;
|
|
41
|
+
/** Body text exactly as it appears between the delimiters. */
|
|
42
|
+
bodyText: string;
|
|
43
|
+
/** Total enclosing blocks of the requested kind before depth selection. */
|
|
44
|
+
enclosingCount: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BraceEvent {
|
|
48
|
+
kind: DelimiterKind | ")" | "]" | "}";
|
|
49
|
+
offset: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const OPENERS: Record<DelimiterKind, string> = { "{": "{", "(": "(", "[": "[" };
|
|
53
|
+
const CLOSERS: Record<DelimiterKind, string> = { "{": "}", "(": ")", "[": "]" };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walk `text` and emit positions of opening and closing delimiters that lie
|
|
57
|
+
* outside strings and comments.
|
|
58
|
+
*/
|
|
59
|
+
export function scanDelimiters(text: string): BraceEvent[] {
|
|
60
|
+
const out: BraceEvent[] = [];
|
|
61
|
+
const len = text.length;
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < len) {
|
|
64
|
+
const ch = text[i]!;
|
|
65
|
+
// Line comment `// …` to end of line.
|
|
66
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
67
|
+
i += 2;
|
|
68
|
+
while (i < len && text[i] !== "\n") i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Hash line comment `# …` for shell/Python-like — but only when at start
|
|
72
|
+
// of a token, to avoid mangling C preprocessor lines (`#include`). We
|
|
73
|
+
// treat any `#` at column 0 or after whitespace as a line comment, which
|
|
74
|
+
// is a heuristic that's also fine for `#include` (no braces follow on
|
|
75
|
+
// the same line in practice for our use case).
|
|
76
|
+
if (ch === "#" && (i === 0 || text[i - 1] === "\n" || text[i - 1] === " " || text[i - 1] === "\t")) {
|
|
77
|
+
// Not enabled: too aggressive for C/C++/Rust files. Skip.
|
|
78
|
+
}
|
|
79
|
+
// Block comment `/* … */`.
|
|
80
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
81
|
+
i += 2;
|
|
82
|
+
while (i < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
83
|
+
if (i < len) i += 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// String literals: ", ', `. Backslash-escape aware.
|
|
87
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
88
|
+
const quote = ch;
|
|
89
|
+
i++;
|
|
90
|
+
while (i < len) {
|
|
91
|
+
const c = text[i]!;
|
|
92
|
+
if (c === "\\") {
|
|
93
|
+
i += 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === quote) {
|
|
97
|
+
i++;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (c === "\n" && (quote === '"' || quote === "'")) {
|
|
101
|
+
// Unterminated string; stop scanning this literal so we
|
|
102
|
+
// don't swallow the rest of the file.
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "{" || ch === "(" || ch === "[") {
|
|
110
|
+
out.push({ kind: ch, offset: i });
|
|
111
|
+
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
112
|
+
out.push({ kind: ch, offset: i });
|
|
113
|
+
}
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface OpenFrame {
|
|
120
|
+
kind: DelimiterKind;
|
|
121
|
+
offset: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a list of balanced (open, close) ranges by walking the events from
|
|
126
|
+
* `scanDelimiters`. Mismatched closers are skipped (the file may be partially
|
|
127
|
+
* malformed), and unclosed openers at EOF are dropped.
|
|
128
|
+
*/
|
|
129
|
+
function pairBlocks(events: BraceEvent[]): { open: OpenFrame; closeOffset: number }[] {
|
|
130
|
+
const stack: OpenFrame[] = [];
|
|
131
|
+
const pairs: { open: OpenFrame; closeOffset: number }[] = [];
|
|
132
|
+
for (const ev of events) {
|
|
133
|
+
if (ev.kind === "{" || ev.kind === "(" || ev.kind === "[") {
|
|
134
|
+
stack.push({ kind: ev.kind, offset: ev.offset });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Closer.
|
|
138
|
+
const expected: DelimiterKind | null =
|
|
139
|
+
ev.kind === "}" ? "{" : ev.kind === ")" ? "(" : ev.kind === "]" ? "[" : null;
|
|
140
|
+
if (!expected) continue;
|
|
141
|
+
// Pop until we find the matching opener, but only commit pairs when
|
|
142
|
+
// kinds match. This tolerates small skews from raw strings or other
|
|
143
|
+
// unsupported constructs without exploding the search.
|
|
144
|
+
const top = stack[stack.length - 1];
|
|
145
|
+
if (top?.kind === expected) {
|
|
146
|
+
stack.pop();
|
|
147
|
+
pairs.push({ open: top, closeOffset: ev.offset });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return pairs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function lineToOffset(text: string, line: number): number {
|
|
154
|
+
let n = 1;
|
|
155
|
+
let i = 0;
|
|
156
|
+
while (i < text.length && n < line) {
|
|
157
|
+
if (text[i] === "\n") n++;
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
return i;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function offsetToLine(text: string, offset: number): number {
|
|
164
|
+
let n = 1;
|
|
165
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
166
|
+
if (text[i] === "\n") n++;
|
|
167
|
+
}
|
|
168
|
+
return n;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function lineIndentAt(text: string, lineNumber: number): string {
|
|
172
|
+
const start = lineToOffset(text, lineNumber);
|
|
173
|
+
let i = start;
|
|
174
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
|
|
175
|
+
return text.slice(start, i);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractRange(text: string, start: number, end: number): string {
|
|
179
|
+
return text.slice(start, end);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface FindBlockOptions {
|
|
183
|
+
kind?: DelimiterKind;
|
|
184
|
+
depth?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface FindBlockError {
|
|
188
|
+
message: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find the enclosing balanced block of `kind` containing `targetLine`
|
|
193
|
+
* (1-indexed), at the requested ancestor `depth` (0 = innermost).
|
|
194
|
+
*
|
|
195
|
+
* Returns an error object when no such block exists.
|
|
196
|
+
*/
|
|
197
|
+
export function findEnclosingBlock(
|
|
198
|
+
text: string,
|
|
199
|
+
targetLine: number,
|
|
200
|
+
options: FindBlockOptions = {},
|
|
201
|
+
): BlockRange | FindBlockError {
|
|
202
|
+
const kind: DelimiterKind = options.kind ?? "{";
|
|
203
|
+
const depth = Math.max(0, Math.floor(options.depth ?? 0));
|
|
204
|
+
|
|
205
|
+
const events = scanDelimiters(text);
|
|
206
|
+
const pairs = pairBlocks(events);
|
|
207
|
+
|
|
208
|
+
// Lines (1-indexed) that bracket the target are considered to contain it.
|
|
209
|
+
// This handles same-line `{ x }` blocks too (openLine == closeLine ==
|
|
210
|
+
// targetLine).
|
|
211
|
+
const enclosing = pairs
|
|
212
|
+
.filter(p => p.open.kind === kind)
|
|
213
|
+
.map(p => ({
|
|
214
|
+
open: p.open,
|
|
215
|
+
closeOffset: p.closeOffset,
|
|
216
|
+
openLine: offsetToLine(text, p.open.offset),
|
|
217
|
+
closeLine: offsetToLine(text, p.closeOffset),
|
|
218
|
+
}))
|
|
219
|
+
.filter(p => p.openLine <= targetLine && targetLine <= p.closeLine);
|
|
220
|
+
if (enclosing.length === 0) {
|
|
221
|
+
return {
|
|
222
|
+
message: `No enclosing \`${kind}\` block contains line ${targetLine}.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// Default ordering is innermost first (largest open offset among containers).
|
|
226
|
+
// When both candidates are entirely on the target line, prefer the outermost
|
|
227
|
+
// same-line block so anchoring a call line targets the containing call before
|
|
228
|
+
// nested argument calls such as `int(port)`. Multi-line nesting keeps the
|
|
229
|
+
// existing innermost-first behavior.
|
|
230
|
+
enclosing.sort((a, b) => {
|
|
231
|
+
const aSingle = a.openLine === targetLine && a.closeLine === targetLine;
|
|
232
|
+
const bSingle = b.openLine === targetLine && b.closeLine === targetLine;
|
|
233
|
+
if (aSingle && bSingle) return a.open.offset - b.open.offset;
|
|
234
|
+
return b.open.offset - a.open.offset;
|
|
235
|
+
});
|
|
236
|
+
if (depth >= enclosing.length) {
|
|
237
|
+
return {
|
|
238
|
+
message: `Requested depth ${depth} exceeds available enclosing \`${kind}\` blocks (${enclosing.length}).`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const chosen = enclosing[depth]!;
|
|
242
|
+
const openOffset = chosen.open.offset;
|
|
243
|
+
const closeOffset = chosen.closeOffset;
|
|
244
|
+
const bodyStart = openOffset + 1;
|
|
245
|
+
const bodyEnd = closeOffset;
|
|
246
|
+
const openLine = chosen.openLine;
|
|
247
|
+
const closeLine = chosen.closeLine;
|
|
248
|
+
const openLineStart = lineToOffset(text, openLine);
|
|
249
|
+
const openerLineIndent = lineIndentAt(text, openLine);
|
|
250
|
+
const bodyText = extractRange(text, bodyStart, bodyEnd);
|
|
251
|
+
const bodyLineIndent = computeBodyLineIndent(text, bodyStart, bodyEnd);
|
|
252
|
+
return {
|
|
253
|
+
openOffset,
|
|
254
|
+
closeOffsetExclusive: closeOffset + 1,
|
|
255
|
+
bodyStart,
|
|
256
|
+
bodyEnd,
|
|
257
|
+
openLine,
|
|
258
|
+
openLineStart,
|
|
259
|
+
closeLine,
|
|
260
|
+
sameLine: openLine === closeLine,
|
|
261
|
+
openerLineIndent,
|
|
262
|
+
bodyLineIndent,
|
|
263
|
+
bodyText,
|
|
264
|
+
enclosingCount: enclosing.length,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function computeBodyLineIndent(text: string, bodyStart: number, bodyEnd: number): string | null {
|
|
269
|
+
// Scan body for the first line whose non-whitespace character lives within
|
|
270
|
+
// [bodyStart, bodyEnd). Return that line's leading whitespace prefix.
|
|
271
|
+
let i = bodyStart;
|
|
272
|
+
// Step over the rest of the opener's line (it may contain trailing
|
|
273
|
+
// whitespace but not body content we want to use as the indent reference).
|
|
274
|
+
while (i < bodyEnd && text[i] !== "\n") i++;
|
|
275
|
+
while (i < bodyEnd) {
|
|
276
|
+
// At line boundary; skip the newline.
|
|
277
|
+
if (text[i] === "\n") i++;
|
|
278
|
+
const lineStart = i;
|
|
279
|
+
while (i < bodyEnd && (text[i] === " " || text[i] === "\t")) i++;
|
|
280
|
+
// Skip blank lines.
|
|
281
|
+
if (i < bodyEnd && text[i] !== "\n") {
|
|
282
|
+
return text.slice(lineStart, i);
|
|
283
|
+
}
|
|
284
|
+
// Skip to end of line.
|
|
285
|
+
while (i < bodyEnd && text[i] !== "\n") i++;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Verify that the agent's body has balanced delimiters of `kind`. Returns an
|
|
292
|
+
* error message when unbalanced, or `null` when fine.
|
|
293
|
+
*/
|
|
294
|
+
export function checkBodyBraceBalance(body: string, kind: DelimiterKind): string | null {
|
|
295
|
+
const events = scanDelimiters(body);
|
|
296
|
+
let opens = 0;
|
|
297
|
+
let closes = 0;
|
|
298
|
+
const opener = OPENERS[kind];
|
|
299
|
+
const closer = CLOSERS[kind];
|
|
300
|
+
for (const e of events) {
|
|
301
|
+
if (e.kind === opener) opens++;
|
|
302
|
+
else if (e.kind === closer) closes++;
|
|
303
|
+
}
|
|
304
|
+
if (opens !== closes) {
|
|
305
|
+
return `Replacement body has unbalanced \`${opener}\`/\`${closer}\` (open=${opens}, close=${closes}).`;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indent helpers used by `splice_block`.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: take strings/lines, return strings/lines. No I/O. Designed
|
|
5
|
+
* to be easy to unit-test in isolation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface IndentStyle {
|
|
9
|
+
kind: "tab" | "space";
|
|
10
|
+
width: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SAMPLE_LINES_FOR_DETECTION = 256;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect whether the file uses tab or space indentation, and the indent width
|
|
17
|
+
* for spaces. Sampling-based; defaults to {kind: "tab", width: 1} when nothing
|
|
18
|
+
* is conclusive.
|
|
19
|
+
*/
|
|
20
|
+
export function detectIndentStyle(text: string): IndentStyle {
|
|
21
|
+
const lines = text.split("\n", SAMPLE_LINES_FOR_DETECTION + 1).slice(0, SAMPLE_LINES_FOR_DETECTION);
|
|
22
|
+
let tabIndented = 0;
|
|
23
|
+
let spaceIndented = 0;
|
|
24
|
+
const spaceWidthCounts = new Map<number, number>();
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (line.length === 0) continue;
|
|
27
|
+
const ch0 = line[0];
|
|
28
|
+
if (ch0 === "\t") {
|
|
29
|
+
tabIndented++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch0 !== " ") continue;
|
|
33
|
+
let count = 0;
|
|
34
|
+
while (count < line.length && line[count] === " ") count++;
|
|
35
|
+
// Skip lines whose entire content is whitespace (no signal).
|
|
36
|
+
if (count === line.length) continue;
|
|
37
|
+
spaceIndented++;
|
|
38
|
+
// Record indent step. Try common values 2, 4, 8 by GCD-ish.
|
|
39
|
+
spaceWidthCounts.set(count, (spaceWidthCounts.get(count) ?? 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
if (tabIndented > spaceIndented) return { kind: "tab", width: 1 };
|
|
42
|
+
if (spaceIndented === 0) return { kind: "tab", width: 1 };
|
|
43
|
+
|
|
44
|
+
// Pick the most common nonzero indent width that divides the others well.
|
|
45
|
+
const candidates = [2, 4, 8];
|
|
46
|
+
let bestCandidate = 4;
|
|
47
|
+
let bestScore = -1;
|
|
48
|
+
for (const cand of candidates) {
|
|
49
|
+
let score = 0;
|
|
50
|
+
for (const [width, count] of spaceWidthCounts) {
|
|
51
|
+
if (width % cand === 0) score += count;
|
|
52
|
+
}
|
|
53
|
+
if (score > bestScore) {
|
|
54
|
+
bestScore = score;
|
|
55
|
+
bestCandidate = cand;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { kind: "space", width: bestCandidate };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Strip the common leading whitespace prefix from every non-empty line.
|
|
63
|
+
* Blank/whitespace-only lines pass through unchanged.
|
|
64
|
+
*
|
|
65
|
+
* Tab and space whitespace count as a single character each here; we look at
|
|
66
|
+
* raw prefix bytes. The block executor re-applies destination indent later,
|
|
67
|
+
* so tab/space mismatches in the agent's own input are normalized by
|
|
68
|
+
* `applyIndent` rather than here.
|
|
69
|
+
*/
|
|
70
|
+
export function stripCommonIndent(lines: readonly string[]): string[] {
|
|
71
|
+
let common: string | null = null;
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (line.trim().length === 0) continue;
|
|
74
|
+
const m = /^[\t ]*/.exec(line);
|
|
75
|
+
const prefix = m ? m[0] : "";
|
|
76
|
+
if (common === null) {
|
|
77
|
+
common = prefix;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Reduce to the longest shared prefix.
|
|
81
|
+
let i = 0;
|
|
82
|
+
const limit = Math.min(common.length, prefix.length);
|
|
83
|
+
while (i < limit && common[i] === prefix[i]) i++;
|
|
84
|
+
common = common.slice(0, i);
|
|
85
|
+
if (common.length === 0) break;
|
|
86
|
+
}
|
|
87
|
+
if (!common) return [...lines];
|
|
88
|
+
return lines.map(line => (line.startsWith(common!) ? line.slice(common!.length) : line));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Re-index leading whitespace of `lines` from the source style to the
|
|
93
|
+
* destination style, then prepend `prefix` to every non-empty line.
|
|
94
|
+
*
|
|
95
|
+
* - Source style is detected from the lines themselves.
|
|
96
|
+
* - Tab→space and space→tab conversion is applied to the *relative*
|
|
97
|
+
* indentation (everything past the common prefix has already been stripped
|
|
98
|
+
* by `stripCommonIndent`, so all leading whitespace here is "extra" indent).
|
|
99
|
+
* - Blank lines stay empty (no trailing whitespace).
|
|
100
|
+
*/
|
|
101
|
+
export function applyIndent(lines: readonly string[], prefix: string, destStyle: IndentStyle): string[] {
|
|
102
|
+
const sourceStyle = detectIndentStyle(lines.join("\n"));
|
|
103
|
+
return lines.map(line => {
|
|
104
|
+
if (line.trim().length === 0) return "";
|
|
105
|
+
const m = /^[\t ]*/.exec(line);
|
|
106
|
+
const leading = m ? m[0] : "";
|
|
107
|
+
const rest = line.slice(leading.length);
|
|
108
|
+
const normalized = normalizeIndent(leading, sourceStyle, destStyle);
|
|
109
|
+
return prefix + normalized + rest;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeIndent(leading: string, source: IndentStyle, dest: IndentStyle): string {
|
|
114
|
+
if (leading.length === 0) return leading;
|
|
115
|
+
// Compute total visual columns of the leading run, treating tabs in the
|
|
116
|
+
// source as `source.width` columns (or 1 column for tab-indented files,
|
|
117
|
+
// where the destination decides the visible width).
|
|
118
|
+
let columns = 0;
|
|
119
|
+
for (const ch of leading) {
|
|
120
|
+
if (ch === "\t") {
|
|
121
|
+
// Treat a source tab as one indent unit. Width = source.width or
|
|
122
|
+
// fallback 4 when source is tab style.
|
|
123
|
+
columns += source.kind === "tab" ? 1 : Math.max(1, Math.floor(source.width));
|
|
124
|
+
} else {
|
|
125
|
+
columns += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (dest.kind === "tab") {
|
|
129
|
+
// Convert visual columns to whole tabs. When source was space-indented,
|
|
130
|
+
// columns is in spaces; divide by source.width to get logical levels.
|
|
131
|
+
let levels: number;
|
|
132
|
+
if (source.kind === "tab") {
|
|
133
|
+
levels = columns;
|
|
134
|
+
} else {
|
|
135
|
+
const w = Math.max(1, source.width);
|
|
136
|
+
levels = Math.round(columns / w);
|
|
137
|
+
}
|
|
138
|
+
return "\t".repeat(Math.max(0, levels));
|
|
139
|
+
}
|
|
140
|
+
// Destination is spaces. Convert to dest.width columns per level.
|
|
141
|
+
let levels: number;
|
|
142
|
+
if (source.kind === "tab") {
|
|
143
|
+
levels = columns; // tabs were 1 column each here
|
|
144
|
+
} else {
|
|
145
|
+
const w = Math.max(1, source.width);
|
|
146
|
+
levels = Math.round(columns / w);
|
|
147
|
+
}
|
|
148
|
+
const out = " ".repeat(Math.max(0, levels) * Math.max(1, dest.width));
|
|
149
|
+
return out;
|
|
150
|
+
}
|