@oh-my-pi/pi-coding-agent 14.5.2 → 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 +26 -0
- package/package.json +7 -7
- 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 +283 -47
- package/src/lsp/utils.ts +6 -36
- package/src/modes/controllers/event-controller.ts +12 -0
- package/src/prompts/tools/atom.md +64 -91
- package/src/prompts/tools/read.md +1 -1
- package/src/session/agent-session.ts +4 -1
- package/src/tools/ast-edit.ts +23 -44
- package/src/tools/ast-grep.ts +18 -42
- package/src/tools/grep.ts +11 -46
- package/src/tools/grouped-file-output.ts +96 -0
- package/src/tools/todo-write.ts +0 -1
package/src/edit/modes/atom.ts
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
*
|
|
3
3
|
* Flat locator + verb edit mode backed by hashline anchors. Each entry carries
|
|
4
|
-
* one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
|
|
4
|
+
* one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`, `sed`).
|
|
5
5
|
* The runtime resolves those verbs into internal anchor-scoped edits and still
|
|
6
6
|
* reuses hashline's staleness scheme (`computeLineHash`) verbatim.
|
|
7
7
|
*
|
|
8
8
|
* External shapes (one entry):
|
|
9
|
-
* { path, loc: "5th", splice: ["..."] }
|
|
10
|
-
* { path, loc: "5th",
|
|
11
|
-
* { path, loc: "5th",
|
|
12
|
-
* { path, loc: "5th",
|
|
13
|
-
* { path, loc: "
|
|
14
|
-
* { path, loc: "
|
|
15
|
-
* { path, loc: "$",
|
|
9
|
+
* { path, loc: "5th", splice: ["..."] } // line replace
|
|
10
|
+
* { path, loc: "(5th)", splice: ["..."] } // block body replace
|
|
11
|
+
* { path, loc: "[5th]", splice: ["..."] } // whole node replace
|
|
12
|
+
* { path, loc: "[5th", splice: ["..."] } // anchor (incl) → closer-1
|
|
13
|
+
* { path, loc: "5th]", splice: ["..."] } // opener+1 → anchor (incl)
|
|
14
|
+
* { path, loc: "5th", pre: [...], splice: [...], post: [...] } // line verbs combinable
|
|
15
|
+
* { path, loc: "$", pre: [...] | post: [...] | sed: {...} } // file-scoped
|
|
16
16
|
*
|
|
17
|
-
* `splice: []`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* `splice: []` deletes; `splice: [""]` replaces with a single blank line. These
|
|
18
|
+
* apply uniformly to single-line and bracketed (region) locators.
|
|
19
|
+
*
|
|
20
|
+
* Bracket forms in `loc` are reserved for `splice` (region replacement). `pre`,
|
|
21
|
+
* `post`, and `sed` reject bracketed locators — they are line-only.
|
|
20
22
|
*
|
|
21
23
|
* For deleting or moving files, the agent should use bash.
|
|
22
24
|
*/
|
|
@@ -29,7 +31,9 @@ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
|
29
31
|
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
30
32
|
import { outputMeta } from "../../tools/output-meta";
|
|
31
33
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
34
|
+
import { checkBodyBraceBalance, type DelimiterKind, findEnclosingBlock } from "../block";
|
|
32
35
|
import { generateDiffString } from "../diff";
|
|
36
|
+
import { applyIndent, detectIndentStyle, stripCommonIndent } from "../indent";
|
|
33
37
|
import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
34
38
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
35
39
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
@@ -59,7 +63,7 @@ const textSchema = Type.Array(Type.String());
|
|
|
59
63
|
export const atomEditSchema = Type.Object(
|
|
60
64
|
{
|
|
61
65
|
loc: Type.String({
|
|
62
|
-
description:
|
|
66
|
+
description: "edit location",
|
|
63
67
|
examples: ["1ab", "$", "src/foo.ts:1ab"],
|
|
64
68
|
}),
|
|
65
69
|
splice: Type.Optional(textSchema),
|
|
@@ -68,10 +72,9 @@ export const atomEditSchema = Type.Object(
|
|
|
68
72
|
sed: Type.Optional(
|
|
69
73
|
Type.Object(
|
|
70
74
|
{
|
|
71
|
-
pat: Type.String({ description: "
|
|
72
|
-
rep: Type.String({ description: "
|
|
73
|
-
g: Type.Optional(Type.Boolean({ description: "global
|
|
74
|
-
F: Type.Optional(Type.Boolean({ description: "literal replace", default: false })),
|
|
75
|
+
pat: Type.String({ description: "regex" }),
|
|
76
|
+
rep: Type.String({ description: "expression to replace with" }),
|
|
77
|
+
g: Type.Optional(Type.Boolean({ description: "global flag", default: false })),
|
|
75
78
|
},
|
|
76
79
|
{
|
|
77
80
|
additionalProperties: false,
|
|
@@ -105,13 +108,41 @@ export type AtomEdit =
|
|
|
105
108
|
| { op: "append_file"; lines: string[] }
|
|
106
109
|
| { op: "prepend_file"; lines: string[] }
|
|
107
110
|
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
|
|
108
|
-
| { op: "sed_file"; spec: SedSpec; expression: string }
|
|
111
|
+
| { op: "sed_file"; spec: SedSpec; expression: string }
|
|
112
|
+
| { op: "splice_block"; pos: Anchor; spec: SpliceBlockSpec; bracket: BracketShape };
|
|
109
113
|
|
|
110
114
|
export interface SedSpec {
|
|
111
115
|
pattern: string;
|
|
112
116
|
replacement: string;
|
|
113
117
|
global: boolean;
|
|
114
|
-
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SpliceBlockSpec {
|
|
121
|
+
body: string[];
|
|
122
|
+
kind: DelimiterKind;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type BracketShape = "none" | "body" | "node" | "left_incl" | "left_excl" | "right_incl" | "right_excl";
|
|
126
|
+
|
|
127
|
+
// File-extension lookup for the block delimiter family used when `loc`
|
|
128
|
+
// has bracket forms. Most languages are brace-family; lisp-family uses `(`.
|
|
129
|
+
// Anything not listed defaults to `{` (covers the long tail of brace-style
|
|
130
|
+
// languages without enumerating every extension).
|
|
131
|
+
const LISP_EXTENSIONS = new Set(["clj", "cljs", "cljc", "edn", "lisp", "lsp", "el", "scm", "ss", "rkt", "fnl"]);
|
|
132
|
+
|
|
133
|
+
function fileExtension(path: string | undefined): string | undefined {
|
|
134
|
+
if (!path) return undefined;
|
|
135
|
+
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
136
|
+
const base = slash >= 0 ? path.slice(slash + 1) : path;
|
|
137
|
+
const dot = base.lastIndexOf(".");
|
|
138
|
+
if (dot <= 0) return undefined;
|
|
139
|
+
return base.slice(dot + 1).toLowerCase();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveBlockDelimiterForPath(path: string | undefined): DelimiterKind {
|
|
143
|
+
const ext = fileExtension(path);
|
|
144
|
+
if (ext && LISP_EXTENSIONS.has(ext)) return "(";
|
|
145
|
+
return "{";
|
|
115
146
|
}
|
|
116
147
|
|
|
117
148
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -135,7 +166,9 @@ const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC
|
|
|
135
166
|
// `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
|
|
136
167
|
// fails the anchor pattern.
|
|
137
168
|
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
138
|
-
const PATH_LOC_SPLIT_RE = new RegExp(
|
|
169
|
+
const PATH_LOC_SPLIT_RE = new RegExp(
|
|
170
|
+
`^(.+?):([\\[(]?${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?[\\])]?)$`,
|
|
171
|
+
);
|
|
139
172
|
|
|
140
173
|
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
141
174
|
let next: Record<string, unknown> | undefined;
|
|
@@ -148,7 +181,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
|
148
181
|
return (next ?? fields) as AtomToolEdit;
|
|
149
182
|
}
|
|
150
183
|
|
|
151
|
-
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
|
|
184
|
+
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor; bracket: BracketShape } | { kind: "file" };
|
|
152
185
|
|
|
153
186
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
154
187
|
// Resolution
|
|
@@ -227,28 +260,52 @@ export function resolveAtomEntryPaths(
|
|
|
227
260
|
}
|
|
228
261
|
|
|
229
262
|
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
230
|
-
|
|
263
|
+
const trimmed = raw.trim();
|
|
264
|
+
if (trimmed === "$") return { kind: "file" };
|
|
265
|
+
|
|
266
|
+
const leading = trimmed[0];
|
|
267
|
+
const trailing = trimmed[trimmed.length - 1];
|
|
268
|
+
const hasLeading = leading === "[" || leading === "(";
|
|
269
|
+
const hasTrailing = trailing === "]" || trailing === ")";
|
|
270
|
+
if ((leading === "(" && trailing === "]") || (leading === "[" && trailing === ")")) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Edit ${editIndex}: mixed bracket inclusivity in loc is ambiguous; use [anchor, (anchor, anchor], anchor), [anchor], or a bare anchor.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let inner = trimmed;
|
|
277
|
+
if (hasLeading) inner = inner.slice(1);
|
|
278
|
+
if (hasTrailing) inner = inner.slice(0, -1);
|
|
279
|
+
|
|
231
280
|
// Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
|
|
232
281
|
// loc (e.g. line content like `i--`) should not trigger the range error.
|
|
233
|
-
const dash =
|
|
282
|
+
const dash = inner.indexOf("-");
|
|
234
283
|
if (dash > 0) {
|
|
235
|
-
const left =
|
|
236
|
-
const right =
|
|
284
|
+
const left = inner.slice(0, dash);
|
|
285
|
+
const right = inner.slice(dash + 1);
|
|
237
286
|
if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
|
|
238
287
|
throw new Error(
|
|
239
288
|
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
|
|
240
289
|
);
|
|
241
290
|
}
|
|
242
291
|
}
|
|
243
|
-
const pos = parseAnchor(
|
|
292
|
+
const pos = parseAnchor(inner, "loc");
|
|
244
293
|
// Capture an optional content suffix after the anchor: `82zu| for (...)`.
|
|
245
294
|
// The suffix acts as a hint for anchor disambiguation when the model's hash
|
|
246
295
|
// is wrong but the content reveals the intended line.
|
|
247
|
-
const hint = extractAnchorContentHint(
|
|
296
|
+
const hint = extractAnchorContentHint(inner);
|
|
248
297
|
if (hint !== undefined) {
|
|
249
298
|
pos.contentHint = hint;
|
|
250
299
|
}
|
|
251
|
-
|
|
300
|
+
|
|
301
|
+
let bracket: BracketShape = "none";
|
|
302
|
+
if (leading === "[" && trailing === "]") bracket = "node";
|
|
303
|
+
else if (leading === "[") bracket = "left_incl";
|
|
304
|
+
else if (leading === "(" && trailing === ")") bracket = "body";
|
|
305
|
+
else if (leading === "(") bracket = "left_excl";
|
|
306
|
+
else if (trailing === "]") bracket = "right_incl";
|
|
307
|
+
else if (trailing === ")") bracket = "right_excl";
|
|
308
|
+
return { kind: "anchor", pos, bracket };
|
|
252
309
|
}
|
|
253
310
|
|
|
254
311
|
function extractAnchorContentHint(raw: string): string | undefined {
|
|
@@ -266,7 +323,7 @@ function extractAnchorContentHint(raw: string): string | undefined {
|
|
|
266
323
|
|
|
267
324
|
function parseSedSpec(input: unknown, editIndex: number): SedSpec {
|
|
268
325
|
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
269
|
-
throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g
|
|
326
|
+
throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?}.`);
|
|
270
327
|
}
|
|
271
328
|
const obj = input as Record<string, unknown>;
|
|
272
329
|
const pat = obj.pat;
|
|
@@ -282,27 +339,24 @@ function parseSedSpec(input: unknown, editIndex: number): SedSpec {
|
|
|
282
339
|
if (typeof rep !== "string") {
|
|
283
340
|
throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
|
|
284
341
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (typeof
|
|
289
|
-
throw new Error(`Edit ${editIndex}: sed
|
|
342
|
+
const rawGlobal = obj.g;
|
|
343
|
+
let global = false;
|
|
344
|
+
if (rawGlobal !== undefined) {
|
|
345
|
+
if (typeof rawGlobal !== "boolean") {
|
|
346
|
+
throw new Error(`Edit ${editIndex}: sed.g must be a boolean when provided.`);
|
|
290
347
|
}
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const literal = readBool("F", false);
|
|
295
|
-
return { pattern: pat, replacement: rep, global, literal };
|
|
348
|
+
global = rawGlobal;
|
|
349
|
+
}
|
|
350
|
+
return { pattern: pat, replacement: rep, global };
|
|
296
351
|
}
|
|
297
352
|
|
|
298
353
|
function formatSedExpression(spec: SedSpec): string {
|
|
299
|
-
const obj: { pat: string; rep: string; g?: boolean
|
|
354
|
+
const obj: { pat: string; rep: string; g?: boolean } = {
|
|
300
355
|
pat: spec.pattern,
|
|
301
356
|
rep: spec.replacement,
|
|
302
357
|
};
|
|
303
358
|
// Only emit non-default flags so error messages stay compact (g defaults false).
|
|
304
359
|
if (spec.global) obj.g = true;
|
|
305
|
-
if (spec.literal) obj.F = true;
|
|
306
360
|
return JSON.stringify(obj);
|
|
307
361
|
}
|
|
308
362
|
|
|
@@ -322,9 +376,6 @@ function applySedToLine(
|
|
|
322
376
|
currentLine: string,
|
|
323
377
|
spec: SedSpec,
|
|
324
378
|
): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
|
|
325
|
-
if (spec.literal) {
|
|
326
|
-
return applyLiteralSed(currentLine, spec);
|
|
327
|
-
}
|
|
328
379
|
let flags = "";
|
|
329
380
|
if (spec.global) flags += "g";
|
|
330
381
|
let re: RegExp | undefined;
|
|
@@ -367,7 +418,7 @@ function classifyAtomEdit(edit: AtomToolEdit): string {
|
|
|
367
418
|
return verbs.length > 0 ? verbs.join("+") : "unknown";
|
|
368
419
|
}
|
|
369
420
|
|
|
370
|
-
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
421
|
+
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0, path?: string): AtomEdit[] {
|
|
371
422
|
const entry = stripNullAtomFields(edit);
|
|
372
423
|
const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
373
424
|
if (verbKeysPresent.length === 0) {
|
|
@@ -399,6 +450,25 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
399
450
|
return resolved;
|
|
400
451
|
}
|
|
401
452
|
|
|
453
|
+
if (loc.bracket !== "none") {
|
|
454
|
+
// Bracketed locator: only `splice` is meaningful (region replacement).
|
|
455
|
+
const hasInvalidVerb = entry.pre !== undefined || entry.post !== undefined || entry.sed !== undefined;
|
|
456
|
+
if (hasInvalidVerb) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Edit ${editIndex}: bracket forms in loc are splice-only; remove pre/post/sed or use a bare anchor.`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (entry.splice === undefined) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`Edit ${editIndex}: bracket loc requires \`splice\`. Bare anchors are line-only; brackets address a region.`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const kind = resolveBlockDelimiterForPath(path);
|
|
467
|
+
const body = hashlineParseText(entry.splice);
|
|
468
|
+
resolved.push({ op: "splice_block", pos: loc.pos, spec: { body, kind }, bracket: loc.bracket });
|
|
469
|
+
return resolved;
|
|
470
|
+
}
|
|
471
|
+
|
|
402
472
|
if (entry.pre !== undefined) {
|
|
403
473
|
resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
|
|
404
474
|
}
|
|
@@ -442,6 +512,7 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
|
442
512
|
case "post":
|
|
443
513
|
case "del":
|
|
444
514
|
case "sed":
|
|
515
|
+
case "splice_block":
|
|
445
516
|
yield edit.pos;
|
|
446
517
|
return;
|
|
447
518
|
default:
|
|
@@ -542,6 +613,151 @@ export interface AtomNoopEdit {
|
|
|
542
613
|
current: string;
|
|
543
614
|
}
|
|
544
615
|
|
|
616
|
+
interface SpliceBlockApplyResult {
|
|
617
|
+
text: string;
|
|
618
|
+
firstChangedLine: number | undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
type SpliceBlockEdit = Extract<AtomEdit, { op: "splice_block" }>;
|
|
622
|
+
|
|
623
|
+
function lineStartOffset(text: string, line: number): number {
|
|
624
|
+
let currentLine = 1;
|
|
625
|
+
let offset = 0;
|
|
626
|
+
while (offset < text.length && currentLine < line) {
|
|
627
|
+
if (text[offset] === "\n") currentLine++;
|
|
628
|
+
offset++;
|
|
629
|
+
}
|
|
630
|
+
return offset;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function lineEndOffset(text: string, line: number): number {
|
|
634
|
+
let offset = lineStartOffset(text, line);
|
|
635
|
+
while (offset < text.length && text[offset] !== "\n") offset++;
|
|
636
|
+
return offset;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function lineEndIncludingNewlineOffset(text: string, line: number): number {
|
|
640
|
+
const offset = lineEndOffset(text, line);
|
|
641
|
+
return text[offset] === "\n" ? offset + 1 : offset;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function spliceBlockLocatorLabel(bracket: BracketShape): string {
|
|
645
|
+
switch (bracket) {
|
|
646
|
+
case "none":
|
|
647
|
+
case "body":
|
|
648
|
+
return "(anchor)";
|
|
649
|
+
case "node":
|
|
650
|
+
return "[anchor]";
|
|
651
|
+
case "left_incl":
|
|
652
|
+
return "[anchor";
|
|
653
|
+
case "left_excl":
|
|
654
|
+
return "(anchor";
|
|
655
|
+
case "right_incl":
|
|
656
|
+
return "anchor]";
|
|
657
|
+
case "right_excl":
|
|
658
|
+
return "anchor)";
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function applySpliceBlockEdits(
|
|
663
|
+
originalText: string,
|
|
664
|
+
edits: SpliceBlockEdit[],
|
|
665
|
+
warnings: string[],
|
|
666
|
+
): SpliceBlockApplyResult {
|
|
667
|
+
// Sort by anchor line descending so applying earlier ops doesn't shift
|
|
668
|
+
// later anchors. (Multiple splice_block ops within one call are assumed
|
|
669
|
+
// non-overlapping; overlapping ranges are not supported.)
|
|
670
|
+
const sorted = [...edits].sort((a, b) => b.pos.line - a.pos.line);
|
|
671
|
+
const destStyle = detectIndentStyle(originalText);
|
|
672
|
+
let text = originalText;
|
|
673
|
+
let firstChangedLine: number | undefined;
|
|
674
|
+
|
|
675
|
+
for (const edit of sorted) {
|
|
676
|
+
const kind: DelimiterKind = edit.spec.kind;
|
|
677
|
+
const found = findEnclosingBlock(text, edit.pos.line, { kind, depth: 0 });
|
|
678
|
+
if ("message" in found) {
|
|
679
|
+
throw new Error(`splice at anchor ${edit.pos.line}: ${found.message}`);
|
|
680
|
+
}
|
|
681
|
+
const replacedLineCount = found.closeLine - found.openLine + 1;
|
|
682
|
+
warnings.push(
|
|
683
|
+
`splice locator ${spliceBlockLocatorLabel(edit.bracket)} replaced \`${kind}\` block at lines ${found.openLine}-${found.closeLine} ` +
|
|
684
|
+
`(${replacedLineCount} lines, 1 of ${found.enclosingCount} enclosing \`${kind}\` blocks).`,
|
|
685
|
+
);
|
|
686
|
+
const balanceErr = checkBodyBraceBalance(edit.spec.body.join("\n"), kind);
|
|
687
|
+
if (balanceErr) {
|
|
688
|
+
throw new Error(`splice at anchor ${edit.pos.line}: ${balanceErr}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const stripped = stripCommonIndent(edit.spec.body);
|
|
692
|
+
const bodyPrefix = found.bodyLineIndent ?? `${found.openerLineIndent}${defaultIndentUnit(destStyle)}`;
|
|
693
|
+
|
|
694
|
+
let replacementText: string;
|
|
695
|
+
let replaceStart: number;
|
|
696
|
+
let replaceEnd: number;
|
|
697
|
+
|
|
698
|
+
switch (edit.bracket) {
|
|
699
|
+
case "node": {
|
|
700
|
+
const indented = applyIndent(stripped, found.openerLineIndent, destStyle);
|
|
701
|
+
replacementText = indented.join("\n");
|
|
702
|
+
replaceStart = found.openLineStart;
|
|
703
|
+
replaceEnd = found.closeOffsetExclusive;
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case "left_incl":
|
|
707
|
+
case "left_excl": {
|
|
708
|
+
const indented = applyIndent(stripped, bodyPrefix, destStyle);
|
|
709
|
+
replacementText = `${indented.join("\n")}\n${found.openerLineIndent}`;
|
|
710
|
+
replaceStart =
|
|
711
|
+
edit.bracket === "left_incl"
|
|
712
|
+
? lineStartOffset(text, edit.pos.line)
|
|
713
|
+
: lineEndIncludingNewlineOffset(text, edit.pos.line);
|
|
714
|
+
replaceEnd = found.bodyEnd;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
case "right_incl":
|
|
718
|
+
case "right_excl": {
|
|
719
|
+
const indented = applyIndent(stripped, bodyPrefix, destStyle);
|
|
720
|
+
replacementText = `\n${indented.join("\n")}\n`;
|
|
721
|
+
replaceStart = found.bodyStart;
|
|
722
|
+
replaceEnd =
|
|
723
|
+
edit.bracket === "right_incl"
|
|
724
|
+
? lineEndIncludingNewlineOffset(text, edit.pos.line)
|
|
725
|
+
: lineStartOffset(text, edit.pos.line);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
case "none":
|
|
729
|
+
case "body": {
|
|
730
|
+
const goInline = found.sameLine && stripped.length === 1;
|
|
731
|
+
if (goInline) {
|
|
732
|
+
const single = stripped.length === 0 ? "" : stripped[0]!.trim();
|
|
733
|
+
const pad = kind === "{" ? " " : "";
|
|
734
|
+
replacementText = single.length > 0 ? `${pad}${single}${pad}` : pad;
|
|
735
|
+
} else {
|
|
736
|
+
const indented = applyIndent(stripped, bodyPrefix, destStyle);
|
|
737
|
+
replacementText = `\n${indented.join("\n")}\n${found.openerLineIndent}`;
|
|
738
|
+
}
|
|
739
|
+
replaceStart = found.bodyStart;
|
|
740
|
+
replaceEnd = found.bodyEnd;
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const before = text.slice(0, replaceStart);
|
|
746
|
+
const after = text.slice(replaceEnd);
|
|
747
|
+
const newText = before + replacementText + after;
|
|
748
|
+
|
|
749
|
+
text = newText;
|
|
750
|
+
if (firstChangedLine === undefined || found.openLine < firstChangedLine) {
|
|
751
|
+
firstChangedLine = found.openLine;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return { text, firstChangedLine };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function defaultIndentUnit(style: { kind: "tab" | "space"; width: number }): string {
|
|
758
|
+
return style.kind === "tab" ? "\t" : " ".repeat(Math.max(1, style.width));
|
|
759
|
+
}
|
|
760
|
+
|
|
545
761
|
export function applyAtomEdits(
|
|
546
762
|
text: string,
|
|
547
763
|
edits: AtomEdit[],
|
|
@@ -578,6 +794,26 @@ export function applyAtomEdits(
|
|
|
578
794
|
}
|
|
579
795
|
validateNoConflictingAnchorOps(effective);
|
|
580
796
|
|
|
797
|
+
// splice_block ops own their entire block range. To keep line numbers sane,
|
|
798
|
+
// they cannot mix with other anchor-scoped ops in the same call. They may
|
|
799
|
+
// coexist with each other (sorted by openLine descending so earlier ops
|
|
800
|
+
// don't shift later anchors).
|
|
801
|
+
const spliceBlockEdits = effective.filter(
|
|
802
|
+
(e): e is Extract<AtomEdit, { op: "splice_block" }> => e.op === "splice_block",
|
|
803
|
+
);
|
|
804
|
+
if (spliceBlockEdits.length > 0) {
|
|
805
|
+
const result = applySpliceBlockEdits(text, spliceBlockEdits, warnings);
|
|
806
|
+
if (result.firstChangedLine !== undefined) {
|
|
807
|
+
if (firstChangedLine === undefined || result.firstChangedLine < firstChangedLine) {
|
|
808
|
+
firstChangedLine = result.firstChangedLine;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// Continue pipeline against the post-splice_block text.
|
|
812
|
+
fileLines.length = 0;
|
|
813
|
+
for (const line of result.text.split("\n")) fileLines.push(line);
|
|
814
|
+
effective = effective.filter(e => e.op !== "splice_block");
|
|
815
|
+
}
|
|
816
|
+
|
|
581
817
|
const trackFirstChanged = (line: number) => {
|
|
582
818
|
if (firstChangedLine === undefined || line < firstChangedLine) {
|
|
583
819
|
firstChangedLine = line;
|
|
@@ -661,7 +897,7 @@ export function applyAtomEdits(
|
|
|
661
897
|
}
|
|
662
898
|
if (literalFallback) {
|
|
663
899
|
warnings.push(
|
|
664
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead.
|
|
900
|
+
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Escape regex metacharacters in the pattern to match as a regex.`,
|
|
665
901
|
);
|
|
666
902
|
}
|
|
667
903
|
replacement = [result];
|
|
@@ -753,7 +989,7 @@ export function applyAtomEdits(
|
|
|
753
989
|
anyMatched = true;
|
|
754
990
|
if (r.literalFallback && !warnedLiteralFallback) {
|
|
755
991
|
warnings.push(
|
|
756
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution.
|
|
992
|
+
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Escape regex metacharacters in the pattern to match as a regex.`,
|
|
757
993
|
);
|
|
758
994
|
warnedLiteralFallback = true;
|
|
759
995
|
}
|
|
@@ -797,7 +1033,7 @@ export async function executeAtomSingle(
|
|
|
797
1033
|
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
798
1034
|
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
799
1035
|
|
|
800
|
-
const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
|
|
1036
|
+
const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i, path));
|
|
801
1037
|
|
|
802
1038
|
enforcePlanModeWrite(session, path, { op: "update" });
|
|
803
1039
|
|
package/src/lsp/utils.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import { type Theme, theme } from "../modes/theme/theme";
|
|
7
|
+
import { formatGroupedFiles } from "../tools/grouped-file-output";
|
|
7
8
|
import { resolveToCwd } from "../tools/path-utils";
|
|
8
9
|
import type {
|
|
9
10
|
CodeAction,
|
|
@@ -154,7 +155,7 @@ const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
|
|
|
154
155
|
/**
|
|
155
156
|
* Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
|
|
156
157
|
* Input: ["path:line:col [sev] msg", ...]
|
|
157
|
-
* Output: "# dir
|
|
158
|
+
* Output: "# dir/\n## file.ts\n line:col [sev] msg"
|
|
158
159
|
*
|
|
159
160
|
* Messages that don't match the expected format are appended ungrouped at the end.
|
|
160
161
|
*/
|
|
@@ -183,41 +184,10 @@ export function formatGroupedDiagnosticMessages(messages: string[]): string {
|
|
|
183
184
|
return ungrouped.join("\n");
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
filesByDirectory.set(directory, []);
|
|
191
|
-
}
|
|
192
|
-
filesByDirectory.get(directory)?.push(filePath);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const lines: string[] = [];
|
|
196
|
-
for (const [directory, directoryFiles] of filesByDirectory) {
|
|
197
|
-
if (directory === ".") {
|
|
198
|
-
for (const filePath of directoryFiles) {
|
|
199
|
-
if (lines.length > 0) {
|
|
200
|
-
lines.push("");
|
|
201
|
-
}
|
|
202
|
-
lines.push(`# ${path.basename(filePath)}`);
|
|
203
|
-
for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
|
|
204
|
-
lines.push(` ${diagnostic}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (lines.length > 0) {
|
|
211
|
-
lines.push("");
|
|
212
|
-
}
|
|
213
|
-
lines.push(`# ${directory}`);
|
|
214
|
-
for (const filePath of directoryFiles) {
|
|
215
|
-
lines.push(`## └─ ${path.basename(filePath)}`);
|
|
216
|
-
for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
|
|
217
|
-
lines.push(` ${diagnostic}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
187
|
+
const grouped = formatGroupedFiles(fileOrder, filePath => ({
|
|
188
|
+
modelLines: (diagnosticsByFile.get(filePath) ?? []).map(diagnostic => ` ${diagnostic}`),
|
|
189
|
+
}));
|
|
190
|
+
const lines: string[] = grouped.model;
|
|
221
191
|
|
|
222
192
|
if (ungrouped.length > 0) {
|
|
223
193
|
lines.push("");
|
|
@@ -52,6 +52,7 @@ export class EventController {
|
|
|
52
52
|
ttsr_triggered: e => this.#handleTtsrTriggered(e),
|
|
53
53
|
todo_reminder: e => this.#handleTodoReminder(e),
|
|
54
54
|
todo_auto_clear: e => this.#handleTodoAutoClear(e),
|
|
55
|
+
irc_message: e => this.#handleIrcMessage(e),
|
|
55
56
|
} satisfies AgentSessionEventHandlers;
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -203,6 +204,17 @@ export class EventController {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
async #handleIrcMessage(event: Extract<AgentSessionEvent, { type: "irc_message" }>): Promise<void> {
|
|
208
|
+
const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
|
|
209
|
+
if (this.#renderedCustomMessages.has(signature)) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.#renderedCustomMessages.add(signature);
|
|
213
|
+
this.#resetReadGroup();
|
|
214
|
+
this.ctx.addMessageToChat(event.message);
|
|
215
|
+
this.ctx.ui.requestRender();
|
|
216
|
+
}
|
|
217
|
+
|
|
206
218
|
async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
|
|
207
219
|
if (this.ctx.streamingComponent && event.message.role === "assistant") {
|
|
208
220
|
this.ctx.streamingMessage = event.message;
|