@oh-my-pi/pi-coding-agent 14.4.1 → 14.4.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 +56 -0
- package/package.json +7 -7
- package/src/cli.ts +0 -1
- package/src/config/prompt-templates.ts +0 -30
- package/src/config/settings-schema.ts +26 -36
- package/src/config/settings.ts +1 -1
- package/src/edit/index.ts +1 -53
- package/src/edit/line-hash.ts +0 -53
- package/src/edit/modes/atom.ts +82 -47
- package/src/edit/modes/hashline.ts +6 -8
- package/src/edit/renderer.ts +6 -8
- package/src/edit/streaming.ts +90 -114
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +10 -15
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/modes/components/settings-defs.ts +0 -5
- package/src/modes/components/tool-execution.ts +2 -5
- package/src/modes/controllers/btw-controller.ts +17 -105
- package/src/modes/controllers/todo-command-controller.ts +537 -0
- package/src/modes/interactive-mode.ts +35 -9
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +17 -0
- package/src/prompts/system/irc-incoming.md +8 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -0
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/atom.md +37 -26
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/grep.md +2 -5
- package/src/prompts/tools/irc.md +49 -0
- package/src/prompts/tools/job.md +11 -0
- package/src/prompts/tools/read.md +12 -13
- package/src/prompts/tools/task.md +1 -1
- package/src/prompts/tools/todo-write.md +14 -5
- package/src/registry/agent-registry.ts +139 -0
- package/src/sdk.ts +35 -0
- package/src/session/agent-session.ts +217 -5
- package/src/session/streaming-output.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +24 -0
- package/src/task/executor.ts +14 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/fetch.ts +18 -6
- package/src/tools/fs-cache-invalidation.ts +0 -5
- package/src/tools/grep.ts +4 -124
- package/src/tools/index.ts +12 -6
- package/src/tools/irc.ts +258 -0
- package/src/tools/job.ts +489 -0
- package/src/tools/match-line-format.ts +7 -6
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/read.ts +36 -126
- package/src/tools/renderers.ts +2 -0
- package/src/tools/todo-write.ts +243 -12
- package/src/utils/edit-mode.ts +1 -2
- package/src/utils/file-display-mode.ts +0 -3
- package/src/cli/read-cli.ts +0 -67
- package/src/commands/read.ts +0 -33
- package/src/edit/modes/chunk.ts +0 -832
- package/src/prompts/tools/cancel-job.md +0 -5
- package/src/prompts/tools/chunk-edit.md +0 -158
- package/src/prompts/tools/poll.md +0 -5
- package/src/prompts/tools/read-chunk.md +0 -73
- package/src/tools/cancel-job.ts +0 -95
- package/src/tools/poll-tool.ts +0 -173
package/src/edit/modes/atom.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
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`, `
|
|
4
|
+
* one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
|
|
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",
|
|
9
|
+
* { path, loc: "5th", splice: ["..."] }
|
|
10
10
|
* { path, loc: "5th", pre: ["..."] }
|
|
11
11
|
* { path, loc: "5th", post: ["..."] }
|
|
12
|
-
* { path, loc: "5th", pre: [...],
|
|
13
|
-
* { path, loc: "
|
|
14
|
-
* { path, loc: "$", post: [...] } // append to
|
|
12
|
+
* { path, loc: "5th", pre: [...], splice: [...], post: [...] }
|
|
13
|
+
* { path, loc: "$", pre: [...] } // prepend to file
|
|
14
|
+
* { path, loc: "$", post: [...] } // append to file
|
|
15
|
+
* { path, loc: "$", sed: "s/foo/bar/" } // sed on every line
|
|
15
16
|
*
|
|
16
|
-
* `
|
|
17
|
+
* `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
|
|
17
18
|
* a blank line. Line ranges are not supported.
|
|
18
19
|
* in the same entry.
|
|
19
20
|
*
|
|
@@ -57,12 +58,11 @@ const textSchema = Type.Array(Type.String());
|
|
|
57
58
|
*/
|
|
58
59
|
export const atomEditSchema = Type.Object(
|
|
59
60
|
{
|
|
60
|
-
path: Type.Optional(Type.String({ description: "file path override", examples: ["src/foo.ts"] })),
|
|
61
61
|
loc: Type.String({
|
|
62
|
-
description: 'edit location: "1ab", "
|
|
63
|
-
examples: ["1ab", "
|
|
62
|
+
description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
|
|
63
|
+
examples: ["1ab", "$", "src/foo.ts:1ab"],
|
|
64
64
|
}),
|
|
65
|
-
|
|
65
|
+
splice: Type.Optional(textSchema),
|
|
66
66
|
pre: Type.Optional(textSchema),
|
|
67
67
|
post: Type.Optional(textSchema),
|
|
68
68
|
sed: Type.Optional(
|
|
@@ -91,13 +91,14 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
|
91
91
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
92
|
|
|
93
93
|
export type AtomEdit =
|
|
94
|
-
| { op: "
|
|
94
|
+
| { op: "splice"; pos: Anchor; lines: string[] }
|
|
95
95
|
| { op: "pre"; pos: Anchor; lines: string[] }
|
|
96
96
|
| { op: "post"; pos: Anchor; lines: string[] }
|
|
97
97
|
| { op: "del"; pos: Anchor }
|
|
98
98
|
| { op: "append_file"; lines: string[] }
|
|
99
99
|
| { op: "prepend_file"; lines: string[] }
|
|
100
|
-
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
|
|
100
|
+
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
|
|
101
|
+
| { op: "sed_file"; spec: SedSpec; expression: string };
|
|
101
102
|
|
|
102
103
|
export interface SedSpec {
|
|
103
104
|
pattern: string;
|
|
@@ -111,9 +112,9 @@ export interface SedSpec {
|
|
|
111
112
|
// Param guards
|
|
112
113
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
114
|
|
|
114
|
-
const ATOM_VERB_KEYS = ["
|
|
115
|
-
type AtomOptionalKey = "
|
|
116
|
-
const ATOM_OPTIONAL_KEYS = ["
|
|
115
|
+
const ATOM_VERB_KEYS = ["splice", "pre", "post", "sed"] as const;
|
|
116
|
+
type AtomOptionalKey = "loc" | (typeof ATOM_VERB_KEYS)[number];
|
|
117
|
+
const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
|
|
117
118
|
|
|
118
119
|
// Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
|
|
119
120
|
// optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
|
|
@@ -141,7 +142,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
|
141
142
|
return (next ?? fields) as AtomToolEdit;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "
|
|
145
|
+
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
|
|
145
146
|
|
|
146
147
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
147
148
|
// Resolution
|
|
@@ -203,10 +204,10 @@ function resolveAtomEntryPath(
|
|
|
203
204
|
loc = split[2]!;
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
|
-
const path = pathOverride ||
|
|
207
|
+
const path = pathOverride || topLevelPath;
|
|
207
208
|
if (!path) {
|
|
208
209
|
throw new Error(
|
|
209
|
-
`Edit ${editIndex}: missing path. Provide a top-level path
|
|
210
|
+
`Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`,
|
|
210
211
|
);
|
|
211
212
|
}
|
|
212
213
|
return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
|
|
@@ -220,8 +221,7 @@ export function resolveAtomEntryPaths(
|
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
223
|
-
if (raw === "
|
|
224
|
-
if (raw === "$") return { kind: "eof" };
|
|
224
|
+
if (raw === "$") return { kind: "file" };
|
|
225
225
|
// Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
|
|
226
226
|
// loc (e.g. line content like `i--`) should not trigger the range error.
|
|
227
227
|
const dash = raw.indexOf("-");
|
|
@@ -230,7 +230,7 @@ function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
|
230
230
|
const right = raw.slice(dash + 1);
|
|
231
231
|
if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
|
|
232
232
|
throw new Error(
|
|
233
|
-
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr"
|
|
233
|
+
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
|
|
234
234
|
);
|
|
235
235
|
}
|
|
236
236
|
}
|
|
@@ -387,57 +387,54 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
387
387
|
);
|
|
388
388
|
}
|
|
389
389
|
if (typeof entry.loc !== "string") {
|
|
390
|
-
throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr"
|
|
390
|
+
throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr" or "$".`);
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
const loc = parseLoc(entry.loc, editIndex);
|
|
394
394
|
const resolved: AtomEdit[] = [];
|
|
395
395
|
|
|
396
|
-
if (loc.kind === "
|
|
397
|
-
if (entry.
|
|
398
|
-
throw new Error(`Edit ${editIndex}: loc "
|
|
396
|
+
if (loc.kind === "file") {
|
|
397
|
+
if (entry.splice !== undefined) {
|
|
398
|
+
throw new Error(`Edit ${editIndex}: loc "$" supports pre, post, and sed (not splice).`);
|
|
399
399
|
}
|
|
400
400
|
if (entry.pre !== undefined) {
|
|
401
401
|
resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
|
|
402
402
|
}
|
|
403
|
-
return resolved;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (loc.kind === "eof") {
|
|
407
|
-
if (entry.set !== undefined || entry.pre !== undefined || entry.sed !== undefined) {
|
|
408
|
-
throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
|
|
409
|
-
}
|
|
410
403
|
if (entry.post !== undefined) {
|
|
411
404
|
resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
|
|
412
405
|
}
|
|
406
|
+
if (entry.sed !== undefined) {
|
|
407
|
+
const spec = parseSedExpression(entry.sed, editIndex);
|
|
408
|
+
resolved.push({ op: "sed_file", spec, expression: entry.sed });
|
|
409
|
+
}
|
|
413
410
|
return resolved;
|
|
414
411
|
}
|
|
415
412
|
|
|
416
413
|
if (entry.pre !== undefined) {
|
|
417
414
|
resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
|
|
418
415
|
}
|
|
419
|
-
if (entry.
|
|
420
|
-
if (Array.isArray(entry.
|
|
421
|
-
// Models often default `
|
|
416
|
+
if (entry.splice !== undefined) {
|
|
417
|
+
if (Array.isArray(entry.splice) && entry.splice.length === 0) {
|
|
418
|
+
// Models often default `splice: []` alongside other verbs (notably `sed`).
|
|
422
419
|
// Treating that combination as an explicit `del` produces a confusing
|
|
423
420
|
// `Conflicting ops` error. When another mutating verb is present, drop
|
|
424
|
-
// the empty `
|
|
421
|
+
// the empty `splice` instead of treating it as a deletion.
|
|
425
422
|
if (entry.sed === undefined) {
|
|
426
423
|
resolved.push({ op: "del", pos: loc.pos });
|
|
427
424
|
}
|
|
428
425
|
} else {
|
|
429
|
-
resolved.push({ op: "
|
|
426
|
+
resolved.push({ op: "splice", pos: loc.pos, lines: hashlineParseText(entry.splice) });
|
|
430
427
|
}
|
|
431
428
|
}
|
|
432
429
|
if (entry.post !== undefined) {
|
|
433
430
|
resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
|
|
434
431
|
}
|
|
435
432
|
if (entry.sed !== undefined) {
|
|
436
|
-
const
|
|
437
|
-
// Models often duplicate intent by sending both an explicit `
|
|
433
|
+
const spliceIsExplicitReplacement = Array.isArray(entry.splice) && entry.splice.length > 0;
|
|
434
|
+
// Models often duplicate intent by sending both an explicit `splice` and a
|
|
438
435
|
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
439
436
|
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
440
|
-
if (!
|
|
437
|
+
if (!spliceIsExplicitReplacement) {
|
|
441
438
|
const spec = parseSedExpression(entry.sed, editIndex);
|
|
442
439
|
resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
|
|
443
440
|
}
|
|
@@ -451,7 +448,7 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
451
448
|
|
|
452
449
|
function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
453
450
|
switch (edit.op) {
|
|
454
|
-
case "
|
|
451
|
+
case "splice":
|
|
455
452
|
case "pre":
|
|
456
453
|
case "post":
|
|
457
454
|
case "del":
|
|
@@ -527,16 +524,16 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
527
524
|
}
|
|
528
525
|
|
|
529
526
|
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
530
|
-
// For each anchor line, at most one mutating op (
|
|
527
|
+
// For each anchor line, at most one mutating op (splice/del).
|
|
531
528
|
// `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
532
529
|
const mutatingPerLine = new Map<number, string>();
|
|
533
530
|
for (const edit of edits) {
|
|
534
|
-
if (edit.op !== "
|
|
531
|
+
if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
|
|
535
532
|
const existing = mutatingPerLine.get(edit.pos.line);
|
|
536
533
|
if (existing) {
|
|
537
534
|
throw new Error(
|
|
538
535
|
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
|
539
|
-
`At most one of
|
|
536
|
+
`At most one of splice/del/sed is allowed per anchor.`,
|
|
540
537
|
);
|
|
541
538
|
}
|
|
542
539
|
mutatingPerLine.set(edit.pos.line, edit.op);
|
|
@@ -551,7 +548,7 @@ function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: stri
|
|
|
551
548
|
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
|
|
552
549
|
if (!enabled) return;
|
|
553
550
|
for (const edit of edits) {
|
|
554
|
-
if (edit.op !== "
|
|
551
|
+
if (edit.op !== "splice" && edit.op !== "pre" && edit.op !== "post") continue;
|
|
555
552
|
if (edit.lines.length === 0) continue;
|
|
556
553
|
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
557
554
|
if (!hasEscapedTabs) continue;
|
|
@@ -614,13 +611,15 @@ export function applyAtomEdits(
|
|
|
614
611
|
// captured idx so multiple pre/post on the same target are emitted in the order
|
|
615
612
|
// the model produced them.
|
|
616
613
|
type Indexed<T> = { edit: T; idx: number };
|
|
617
|
-
type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
|
|
614
|
+
type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" } | { op: "sed_file" }>;
|
|
618
615
|
const anchorEdits: Indexed<AnchorEdit>[] = [];
|
|
619
616
|
const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
|
|
617
|
+
const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
|
|
620
618
|
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
621
619
|
edits.forEach((edit, idx) => {
|
|
622
620
|
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
623
621
|
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
622
|
+
else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
|
|
624
623
|
else anchorEdits.push({ edit, idx });
|
|
625
624
|
});
|
|
626
625
|
|
|
@@ -667,7 +666,7 @@ export function applyAtomEdits(
|
|
|
667
666
|
replacementSet = true;
|
|
668
667
|
anchorDeleted = true;
|
|
669
668
|
break;
|
|
670
|
-
case "
|
|
669
|
+
case "splice":
|
|
671
670
|
replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
|
|
672
671
|
replacementSet = true;
|
|
673
672
|
anchorMutated = true;
|
|
@@ -759,6 +758,42 @@ export function applyAtomEdits(
|
|
|
759
758
|
trackFirstChanged(insertIdx + 1);
|
|
760
759
|
}
|
|
761
760
|
|
|
761
|
+
// Apply sed_file ops last so they observe the post-anchor / post-prepend /
|
|
762
|
+
// post-append state of the file. Each op runs across every content line and
|
|
763
|
+
let warnedLiteralFallback = false;
|
|
764
|
+
sedFileEdits.sort((a, b) => a.idx - b.idx);
|
|
765
|
+
for (const { edit } of sedFileEdits) {
|
|
766
|
+
const hasTrailingNewline = fileLines.length > 1 && fileLines[fileLines.length - 1] === "";
|
|
767
|
+
const upper = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
768
|
+
let anyMatched = false;
|
|
769
|
+
let lastCompileError: string | undefined;
|
|
770
|
+
for (let i = 0; i < upper; i++) {
|
|
771
|
+
const line = fileLines[i] ?? "";
|
|
772
|
+
const r = applySedToLine(line, edit.spec);
|
|
773
|
+
if (r.error) lastCompileError = r.error;
|
|
774
|
+
if (!r.matched) continue;
|
|
775
|
+
anyMatched = true;
|
|
776
|
+
if (r.literalFallback && !warnedLiteralFallback) {
|
|
777
|
+
warnings.push(
|
|
778
|
+
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
|
|
779
|
+
);
|
|
780
|
+
warnedLiteralFallback = true;
|
|
781
|
+
}
|
|
782
|
+
if (r.result !== line) {
|
|
783
|
+
fileLines[i] = r.result;
|
|
784
|
+
trackFirstChanged(i + 1);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!anyMatched) {
|
|
788
|
+
if (lastCompileError !== undefined) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${lastCompileError}`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
762
797
|
return {
|
|
763
798
|
lines: fileLines.join("\n"),
|
|
764
799
|
firstChangedLine,
|
|
@@ -59,7 +59,7 @@ const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
|
59
59
|
`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
|
|
60
60
|
);
|
|
61
61
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
62
|
-
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L
|
|
62
|
+
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
|
|
63
63
|
|
|
64
64
|
type LinePrefixStats = {
|
|
65
65
|
nonEmpty: number;
|
|
@@ -525,7 +525,7 @@ const MISMATCH_CONTEXT = 2;
|
|
|
525
525
|
/**
|
|
526
526
|
* Error thrown when one or more hashline references have stale hashes.
|
|
527
527
|
*
|
|
528
|
-
* Displays grep-style output with
|
|
528
|
+
* Displays grep-style output with `*` marker on mismatched lines and a leading space on
|
|
529
529
|
* surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
|
|
530
530
|
*/
|
|
531
531
|
export class HashlineMismatchError extends Error {
|
|
@@ -604,7 +604,7 @@ export class HashlineMismatchError extends Error {
|
|
|
604
604
|
|
|
605
605
|
lines.push(
|
|
606
606
|
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
|
|
607
|
-
"Use the updated anchors shown below (
|
|
607
|
+
"Use the updated anchors shown below (`*` marks changed lines, leading space marks context) and retry the edit.",
|
|
608
608
|
);
|
|
609
609
|
lines.push("");
|
|
610
610
|
|
|
@@ -621,9 +621,9 @@ export class HashlineMismatchError extends Error {
|
|
|
621
621
|
const prefix = `${lineNum}${hash}`;
|
|
622
622
|
|
|
623
623
|
if (mismatchSet.has(lineNum)) {
|
|
624
|
-
lines.push(
|
|
624
|
+
lines.push(`*${prefix}|${text}`);
|
|
625
625
|
} else {
|
|
626
|
-
lines.push(
|
|
626
|
+
lines.push(` ${prefix}|${text}`);
|
|
627
627
|
}
|
|
628
628
|
}
|
|
629
629
|
return lines.join("\n");
|
|
@@ -1060,7 +1060,6 @@ export interface CompactHashlineDiffPreview {
|
|
|
1060
1060
|
|
|
1061
1061
|
export interface CompactHashlineDiffOptions {
|
|
1062
1062
|
maxUnchangedRun?: number;
|
|
1063
|
-
maxAdditionRun?: number;
|
|
1064
1063
|
maxDeletionRun?: number;
|
|
1065
1064
|
maxOutputLines?: number;
|
|
1066
1065
|
}
|
|
@@ -1216,7 +1215,6 @@ export function buildCompactHashlineDiffPreview(
|
|
|
1216
1215
|
options: CompactHashlineDiffOptions = {},
|
|
1217
1216
|
): CompactHashlineDiffPreview {
|
|
1218
1217
|
const maxUnchangedRun = options.maxUnchangedRun ?? 2;
|
|
1219
|
-
const maxAdditionRun = options.maxAdditionRun ?? 2;
|
|
1220
1218
|
const maxDeletionRun = options.maxDeletionRun ?? 2;
|
|
1221
1219
|
const maxOutputLines = options.maxOutputLines ?? 16;
|
|
1222
1220
|
|
|
@@ -1235,7 +1233,7 @@ export function buildCompactHashlineDiffPreview(
|
|
|
1235
1233
|
break;
|
|
1236
1234
|
case "+":
|
|
1237
1235
|
addedLines += run.lines.length;
|
|
1238
|
-
out.push(...
|
|
1236
|
+
out.push(...run.lines);
|
|
1239
1237
|
break;
|
|
1240
1238
|
case "-":
|
|
1241
1239
|
removedLines += run.lines.length;
|
package/src/edit/renderer.ts
CHANGED
|
@@ -94,7 +94,7 @@ interface EditRenderArgs {
|
|
|
94
94
|
*/
|
|
95
95
|
previewDiff?: string;
|
|
96
96
|
__partialJson?: string;
|
|
97
|
-
// Hashline
|
|
97
|
+
// Hashline mode fields
|
|
98
98
|
edits?: EditRenderEntry[];
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -141,7 +141,7 @@ export interface EditRenderContext {
|
|
|
141
141
|
editMode?: EditMode;
|
|
142
142
|
/** Pre-computed diff preview (computed before tool executes) */
|
|
143
143
|
editDiffPreview?: DiffResult | DiffError;
|
|
144
|
-
/** Multi-file streaming diff preview (
|
|
144
|
+
/** Multi-file streaming diff preview (edits spanning several files) */
|
|
145
145
|
perFileDiffPreview?: PerFileDiffPreview[];
|
|
146
146
|
/** Function to render diff text with syntax highlighting */
|
|
147
147
|
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
|
|
@@ -151,11 +151,9 @@ const EDIT_STREAMING_PREVIEW_LINES = 12;
|
|
|
151
151
|
const CALL_TEXT_PREVIEW_LINES = 6;
|
|
152
152
|
const CALL_TEXT_PREVIEW_WIDTH = 80;
|
|
153
153
|
|
|
154
|
-
/** Extract file path from an edit entry
|
|
154
|
+
/** Extract file path from an edit entry. */
|
|
155
155
|
function filePathFromEditEntry(p: string | undefined): string | undefined {
|
|
156
|
-
|
|
157
|
-
const ci = /^[a-zA-Z]:[/\\]/.test(p) ? p.indexOf(":", 2) : p.indexOf(":");
|
|
158
|
-
return ci === -1 ? p : p.slice(0, ci);
|
|
156
|
+
return p ?? undefined;
|
|
159
157
|
}
|
|
160
158
|
|
|
161
159
|
function decodePartialJsonStringFragment(fragment: string): string {
|
|
@@ -284,7 +282,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
|
|
|
284
282
|
const parts: string[] = [];
|
|
285
283
|
for (const preview of previews) {
|
|
286
284
|
if (!preview.diff && !preview.error) continue;
|
|
287
|
-
const header = uiTheme.fg("dim", `\n\n
|
|
285
|
+
const header = uiTheme.fg("dim", `\n\n\u2500\u2500 ${shortenPath(preview.path)} \u2500\u2500`);
|
|
288
286
|
if (preview.error) {
|
|
289
287
|
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
|
|
290
288
|
continue;
|
|
@@ -303,7 +301,7 @@ function getCallPreview(
|
|
|
303
301
|
renderContext: EditRenderContext | undefined,
|
|
304
302
|
): string {
|
|
305
303
|
const multi = renderContext?.perFileDiffPreview;
|
|
306
|
-
if (multi && multi.length >
|
|
304
|
+
if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
|
|
307
305
|
return formatMultiFileStreamingDiff(multi, uiTheme);
|
|
308
306
|
}
|
|
309
307
|
if (args.previewDiff) {
|
package/src/edit/streaming.ts
CHANGED
|
@@ -16,7 +16,6 @@ import type { Theme } from "../modes/theme/theme";
|
|
|
16
16
|
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
17
17
|
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
|
18
18
|
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
|
19
|
-
import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
|
|
20
19
|
import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
|
|
21
20
|
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
|
|
22
21
|
import type { ReplaceEditEntry } from "./modes/replace";
|
|
@@ -126,6 +125,38 @@ export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: stri
|
|
|
126
125
|
return [...edits];
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
// -----------------------------------------------------------------------------
|
|
129
|
+
// Multi-file grouping
|
|
130
|
+
// -----------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** Cap on how many distinct files a streaming preview will render diffs for. */
|
|
133
|
+
const MAX_PREVIEW_FILES = 5;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Group a list of edits by their effective `path` (per-edit `path` falls back
|
|
137
|
+
* to the top-level `args.path`). Insertion order is preserved and the number of
|
|
138
|
+
* distinct buckets is capped at {@link MAX_PREVIEW_FILES}.
|
|
139
|
+
*/
|
|
140
|
+
function groupEditsByPath<T extends { path?: string }>(
|
|
141
|
+
edits: readonly T[],
|
|
142
|
+
fallbackPath: string | undefined,
|
|
143
|
+
): Map<string, Array<T & { path: string }>> {
|
|
144
|
+
const groups = new Map<string, Array<T & { path: string }>>();
|
|
145
|
+
for (const edit of edits) {
|
|
146
|
+
if (!edit) continue;
|
|
147
|
+
const editPath = edit.path ?? fallbackPath;
|
|
148
|
+
if (!editPath) continue;
|
|
149
|
+
let bucket = groups.get(editPath);
|
|
150
|
+
if (!bucket) {
|
|
151
|
+
if (groups.size >= MAX_PREVIEW_FILES) continue;
|
|
152
|
+
bucket = [];
|
|
153
|
+
groups.set(editPath, bucket);
|
|
154
|
+
}
|
|
155
|
+
bucket.push({ ...edit, path: editPath });
|
|
156
|
+
}
|
|
157
|
+
return groups;
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
// -----------------------------------------------------------------------------
|
|
130
161
|
// Strategies
|
|
131
162
|
// -----------------------------------------------------------------------------
|
|
@@ -142,22 +173,26 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
|
|
|
142
173
|
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
143
174
|
},
|
|
144
175
|
async computeDiffPreview(args, ctx) {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
const groups = groupEditsByPath(args.edits ?? [], args.path);
|
|
177
|
+
if (groups.size === 0) return null;
|
|
178
|
+
const previews: PerFileDiffPreview[] = [];
|
|
179
|
+
for (const [path, fileEdits] of groups) {
|
|
180
|
+
const first = fileEdits[0];
|
|
181
|
+
if (!first || first.old_text === undefined || first.new_text === undefined) continue;
|
|
182
|
+
ctx.signal.throwIfAborted();
|
|
183
|
+
const result = await computeEditDiff(
|
|
184
|
+
path,
|
|
185
|
+
first.old_text,
|
|
186
|
+
first.new_text,
|
|
187
|
+
ctx.cwd,
|
|
188
|
+
ctx.allowFuzzy ?? true,
|
|
189
|
+
first.all,
|
|
190
|
+
ctx.fuzzyThreshold,
|
|
191
|
+
);
|
|
192
|
+
ctx.signal.throwIfAborted();
|
|
193
|
+
previews.push(toPerFilePreview(path, result));
|
|
194
|
+
}
|
|
195
|
+
return previews.length > 0 ? previews : null;
|
|
161
196
|
},
|
|
162
197
|
renderStreamingFallback() {
|
|
163
198
|
return "";
|
|
@@ -176,17 +211,22 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
|
|
176
211
|
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
177
212
|
},
|
|
178
213
|
async computeDiffPreview(args, ctx) {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
ctx.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
214
|
+
const groups = groupEditsByPath(args.edits ?? [], args.path);
|
|
215
|
+
if (groups.size === 0) return null;
|
|
216
|
+
const previews: PerFileDiffPreview[] = [];
|
|
217
|
+
for (const [path, fileEdits] of groups) {
|
|
218
|
+
const first = fileEdits[0];
|
|
219
|
+
if (!first) continue;
|
|
220
|
+
ctx.signal.throwIfAborted();
|
|
221
|
+
const result = await computePatchDiff(
|
|
222
|
+
{ path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
|
|
223
|
+
ctx.cwd,
|
|
224
|
+
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
|
225
|
+
);
|
|
226
|
+
ctx.signal.throwIfAborted();
|
|
227
|
+
previews.push(toPerFilePreview(path, result));
|
|
228
|
+
}
|
|
229
|
+
return previews.length > 0 ? previews : null;
|
|
190
230
|
},
|
|
191
231
|
renderStreamingFallback() {
|
|
192
232
|
return "";
|
|
@@ -205,83 +245,14 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
205
245
|
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
206
246
|
},
|
|
207
247
|
async computeDiffPreview(args, ctx) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
if (!path) return null;
|
|
211
|
-
const fileEdits = (args.edits ?? [])
|
|
212
|
-
.map(e => {
|
|
213
|
-
if (!e || typeof e !== "object") return undefined;
|
|
214
|
-
const entryPath = (e as { path?: string }).path ?? args.path;
|
|
215
|
-
if (!entryPath || entryPath !== path) return undefined;
|
|
216
|
-
return { ...(e as HashlineToolEdit), path } as HashlineToolEdit & { path: string };
|
|
217
|
-
})
|
|
218
|
-
.filter((e): e is HashlineToolEdit & { path: string } => e !== undefined);
|
|
219
|
-
ctx.signal.throwIfAborted();
|
|
220
|
-
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
|
|
221
|
-
ctx.signal.throwIfAborted();
|
|
222
|
-
return [toPerFilePreview(path, result)];
|
|
223
|
-
},
|
|
224
|
-
renderStreamingFallback() {
|
|
225
|
-
return "";
|
|
226
|
-
},
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
interface ChunkArgs {
|
|
230
|
-
path?: string;
|
|
231
|
-
edits?: ChunkToolEdit[];
|
|
232
|
-
__partialJson?: string;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
|
|
236
|
-
extractCompleteEdits(args, partialJson) {
|
|
237
|
-
if (!args?.edits) return args;
|
|
238
|
-
let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
|
|
239
|
-
// Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
|
|
240
|
-
// `null` literals), `partial-json` may have already surfaced the last
|
|
241
|
-
// entry with `write === null`. When that entry's `}` hasn't closed
|
|
242
|
-
// yet, it has already been dropped above. But if dropping was not
|
|
243
|
-
// triggered (e.g. list still open and no new `{` after), also drop the
|
|
244
|
-
// trailing null-write entry so the preview does not flicker with an
|
|
245
|
-
// error for an incomplete string/null literal.
|
|
246
|
-
if (partialJson && edits.length > 0) {
|
|
247
|
-
const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
|
|
248
|
-
const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
|
|
249
|
-
if (last && endsInPartialNull && last.write === null) {
|
|
250
|
-
edits = edits.slice(0, -1);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return { ...args, edits };
|
|
254
|
-
},
|
|
255
|
-
async computeDiffPreview(args, ctx) {
|
|
256
|
-
const edits = args.edits ?? [];
|
|
257
|
-
if (edits.length === 0) return null;
|
|
258
|
-
// Group edits by file path
|
|
259
|
-
const groups = new Map<string, ChunkToolEdit[]>();
|
|
260
|
-
const fileOrder: string[] = [];
|
|
261
|
-
for (const edit of edits) {
|
|
262
|
-
if (!edit) continue;
|
|
263
|
-
const editPath = edit.path ?? args.path;
|
|
264
|
-
if (!editPath) continue;
|
|
265
|
-
const { filePath } = parseChunkEditPath(editPath);
|
|
266
|
-
if (!filePath) continue;
|
|
267
|
-
let bucket = groups.get(filePath);
|
|
268
|
-
if (!bucket) {
|
|
269
|
-
bucket = [];
|
|
270
|
-
groups.set(filePath, bucket);
|
|
271
|
-
fileOrder.push(filePath);
|
|
272
|
-
}
|
|
273
|
-
bucket.push({ ...edit, path: editPath });
|
|
274
|
-
}
|
|
275
|
-
if (fileOrder.length === 0) return null;
|
|
276
|
-
|
|
277
|
-
const MAX_FILES = 5;
|
|
278
|
-
const selected = fileOrder.slice(0, MAX_FILES);
|
|
248
|
+
const groups = groupEditsByPath(args.edits ?? [], args.path);
|
|
249
|
+
if (groups.size === 0) return null;
|
|
279
250
|
const previews: PerFileDiffPreview[] = [];
|
|
280
|
-
for (const
|
|
251
|
+
for (const [path, fileEdits] of groups) {
|
|
281
252
|
ctx.signal.throwIfAborted();
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
previews.push(toPerFilePreview(
|
|
253
|
+
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
|
|
254
|
+
ctx.signal.throwIfAborted();
|
|
255
|
+
previews.push(toPerFilePreview(path, result));
|
|
285
256
|
}
|
|
286
257
|
return previews;
|
|
287
258
|
},
|
|
@@ -311,16 +282,22 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
|
|
|
311
282
|
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
|
|
312
283
|
}
|
|
313
284
|
}
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
285
|
+
const groups = groupEditsByPath(entries, undefined);
|
|
286
|
+
if (groups.size === 0) return null;
|
|
287
|
+
const previews: PerFileDiffPreview[] = [];
|
|
288
|
+
for (const [path, fileEntries] of groups) {
|
|
289
|
+
const first = fileEntries[0];
|
|
290
|
+
if (!first) continue;
|
|
291
|
+
ctx.signal.throwIfAborted();
|
|
292
|
+
const result = await computePatchDiff(
|
|
293
|
+
{ path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
|
|
294
|
+
ctx.cwd,
|
|
295
|
+
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
|
296
|
+
);
|
|
297
|
+
ctx.signal.throwIfAborted();
|
|
298
|
+
previews.push(toPerFilePreview(path, result));
|
|
299
|
+
}
|
|
300
|
+
return previews.length > 0 ? previews : null;
|
|
324
301
|
},
|
|
325
302
|
renderStreamingFallback() {
|
|
326
303
|
return "";
|
|
@@ -365,7 +342,6 @@ export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknow
|
|
|
365
342
|
replace: replaceStrategy as EditStreamingStrategy<unknown>,
|
|
366
343
|
patch: patchStrategy as EditStreamingStrategy<unknown>,
|
|
367
344
|
hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
|
|
368
|
-
chunk: chunkStrategy as EditStreamingStrategy<unknown>,
|
|
369
345
|
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
|
|
370
346
|
vim: vimStrategy,
|
|
371
347
|
atom: atomStrategy as EditStreamingStrategy<unknown>,
|