@oh-my-pi/pi-coding-agent 14.5.0 → 14.5.2
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 +25 -0
- package/package.json +8 -8
- package/src/edit/modes/atom.ts +77 -112
- package/src/edit/modes/hashline.ts +0 -50
- package/src/edit/renderer.ts +16 -31
- package/src/modes/components/diff.ts +5 -4
- package/src/modes/components/status-line.ts +36 -0
- package/src/modes/controllers/event-controller.ts +23 -4
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/atom.md +16 -11
- package/src/session/agent-session.ts +24 -0
- package/src/tools/checkpoint.ts +2 -0
- package/src/tools/exit-plan-mode.ts +1 -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 -0
- package/src/tools/yield.ts +1 -0
- package/src/utils/tool-choice.ts +6 -1
- package/src/web/search/providers/zai.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.5.2] - 2026-04-26
|
|
6
|
+
### Breaking Changes
|
|
7
|
+
|
|
8
|
+
- 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)
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Changed atom `sed` replacements to be global by default and require `g:false` for first-match-only replacements
|
|
13
|
+
- Changed anchor validation so multiple `sed` operations can target the same line and run sequentially
|
|
14
|
+
- 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
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed zero-length regex `sed` patterns (for example `()`, `^`, `$`) to fall back to literal substring matching instead of producing insertion-like replacements
|
|
19
|
+
- Fixed `sed` chaining so each edit on the same anchor applies to the latest line state from prior replacements
|
|
20
|
+
|
|
21
|
+
## [14.5.1] - 2026-04-26
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- Removed `\t` escaped-tab indentation autocorrect from hashline and atom edit modes (and the `PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS` environment toggle); literal `\t` in edit content is now preserved verbatim
|
|
26
|
+
- Removed the suspicious-`\uDDDD` warning preflight from hashline edits
|
|
27
|
+
- Removed the hand-rolled JSON unescape fallback in the streaming edit-arg renderer; partial fragments that fail `JSON.parse` are now surfaced raw rather than partially decoded with a non-spec-compliant unescaper that mishandled lone surrogates
|
|
28
|
+
|
|
5
29
|
## [14.4.3] - 2026-04-26
|
|
6
30
|
### Added
|
|
7
31
|
|
|
@@ -754,6 +778,7 @@
|
|
|
754
778
|
- Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata
|
|
755
779
|
- 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
|
|
756
780
|
- Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen
|
|
781
|
+
- Fixed status line dropping git branch name when path is long by shrinking the path segment before dropping other segments
|
|
757
782
|
|
|
758
783
|
## [13.18.0] - 2026-04-02
|
|
759
784
|
|
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.2",
|
|
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.2",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.5.2",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.5.2",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.5.2",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.5.2",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.5.2",
|
|
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": {
|
package/src/edit/modes/atom.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* { path, loc: "5th", pre: [...], splice: [...], post: [...] }
|
|
13
13
|
* { path, loc: "$", pre: [...] } // prepend to file
|
|
14
14
|
* { path, loc: "$", post: [...] } // append to file
|
|
15
|
-
* { path, loc: "$", sed:
|
|
15
|
+
* { path, loc: "$", sed: { pat, rep, g?, F? } } // sed on every line
|
|
16
16
|
*
|
|
17
17
|
* `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
|
|
18
18
|
* a blank line. Line ranges are not supported.
|
|
@@ -66,10 +66,17 @@ export const atomEditSchema = Type.Object(
|
|
|
66
66
|
pre: Type.Optional(textSchema),
|
|
67
67
|
post: Type.Optional(textSchema),
|
|
68
68
|
sed: Type.Optional(
|
|
69
|
-
Type.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
Type.Object(
|
|
70
|
+
{
|
|
71
|
+
pat: Type.String({ description: "pattern to find" }),
|
|
72
|
+
rep: Type.String({ description: "replacement text" }),
|
|
73
|
+
g: Type.Optional(Type.Boolean({ description: "global replace", default: false })),
|
|
74
|
+
F: Type.Optional(Type.Boolean({ description: "literal replace", default: false })),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
),
|
|
73
80
|
),
|
|
74
81
|
},
|
|
75
82
|
{ additionalProperties: false },
|
|
@@ -104,7 +111,6 @@ export interface SedSpec {
|
|
|
104
111
|
pattern: string;
|
|
105
112
|
replacement: string;
|
|
106
113
|
global: boolean;
|
|
107
|
-
ignoreCase: boolean;
|
|
108
114
|
literal: boolean;
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -258,70 +264,46 @@ function extractAnchorContentHint(raw: string): string | undefined {
|
|
|
258
264
|
return hint;
|
|
259
265
|
}
|
|
260
266
|
|
|
261
|
-
function
|
|
262
|
-
if (typeof
|
|
263
|
-
throw new Error(
|
|
264
|
-
`Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
|
|
265
|
-
);
|
|
267
|
+
function parseSedSpec(input: unknown, editIndex: number): SedSpec {
|
|
268
|
+
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?, F?}.`);
|
|
266
270
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
bodyStart = 1;
|
|
271
|
+
const obj = input as Record<string, unknown>;
|
|
272
|
+
const pat = obj.pat;
|
|
273
|
+
const rep = obj.rep;
|
|
274
|
+
if (typeof pat !== "string" || pat.length === 0) {
|
|
275
|
+
throw new Error(`Edit ${editIndex}: sed.pat must be a non-empty string.`);
|
|
273
276
|
}
|
|
274
|
-
|
|
275
|
-
if (/[\sA-Za-z0-9\\]/.test(delim)) {
|
|
277
|
+
if (pat.includes("\n")) {
|
|
276
278
|
throw new Error(
|
|
277
|
-
`Edit ${editIndex}: sed
|
|
279
|
+
`Edit ${editIndex}: sed.pat must be a single line; contains a newline. Use \`splice\` to replace multiple lines, anchoring the first changed line and listing replacement lines in the array.`,
|
|
278
280
|
);
|
|
279
281
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
let i = bodyStart + 1;
|
|
283
|
-
while (i < raw.length) {
|
|
284
|
-
const c = raw[i]!;
|
|
285
|
-
if (c === "\\" && raw[i + 1] === delim) {
|
|
286
|
-
parts[bucket] += delim;
|
|
287
|
-
i += 2;
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (c === delim) {
|
|
291
|
-
if (bucket === 0) {
|
|
292
|
-
bucket = 1;
|
|
293
|
-
i += 1;
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
i += 1;
|
|
297
|
-
break;
|
|
298
|
-
}
|
|
299
|
-
parts[bucket] += c;
|
|
300
|
-
i += 1;
|
|
301
|
-
}
|
|
302
|
-
if (bucket !== 1) {
|
|
303
|
-
throw new Error(
|
|
304
|
-
`Edit ${editIndex}: malformed sed expression ${JSON.stringify(raw)}. Expected three ${JSON.stringify(delim)} separators.`,
|
|
305
|
-
);
|
|
282
|
+
if (typeof rep !== "string") {
|
|
283
|
+
throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
|
|
306
284
|
}
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (f === "g") global = true;
|
|
313
|
-
else if (f === "i") ignoreCase = true;
|
|
314
|
-
else if (f === "F") literal = true;
|
|
315
|
-
else {
|
|
316
|
-
throw new Error(
|
|
317
|
-
`Edit ${editIndex}: unknown sed flag ${JSON.stringify(f)}. Supported flags: g (all), i (case-insensitive), F (literal).`,
|
|
318
|
-
);
|
|
285
|
+
const readBool = (key: "g" | "F", defaultValue: boolean): boolean => {
|
|
286
|
+
const v = obj[key];
|
|
287
|
+
if (v === undefined) return defaultValue;
|
|
288
|
+
if (typeof v !== "boolean") {
|
|
289
|
+
throw new Error(`Edit ${editIndex}: sed.${key} must be a boolean when provided.`);
|
|
319
290
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return { pattern:
|
|
291
|
+
return v;
|
|
292
|
+
};
|
|
293
|
+
const global = readBool("g", false);
|
|
294
|
+
const literal = readBool("F", false);
|
|
295
|
+
return { pattern: pat, replacement: rep, global, literal };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatSedExpression(spec: SedSpec): string {
|
|
299
|
+
const obj: { pat: string; rep: string; g?: boolean; F?: boolean } = {
|
|
300
|
+
pat: spec.pattern,
|
|
301
|
+
rep: spec.replacement,
|
|
302
|
+
};
|
|
303
|
+
// Only emit non-default flags so error messages stay compact (g defaults false).
|
|
304
|
+
if (spec.global) obj.g = true;
|
|
305
|
+
if (spec.literal) obj.F = true;
|
|
306
|
+
return JSON.stringify(obj);
|
|
325
307
|
}
|
|
326
308
|
|
|
327
309
|
function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
|
|
@@ -345,7 +327,6 @@ function applySedToLine(
|
|
|
345
327
|
}
|
|
346
328
|
let flags = "";
|
|
347
329
|
if (spec.global) flags += "g";
|
|
348
|
-
if (spec.ignoreCase) flags += "i";
|
|
349
330
|
let re: RegExp | undefined;
|
|
350
331
|
let compileError: string | undefined;
|
|
351
332
|
try {
|
|
@@ -357,18 +338,13 @@ function applySedToLine(
|
|
|
357
338
|
re.lastIndex = 0;
|
|
358
339
|
const probe = re.exec(currentLine);
|
|
359
340
|
re.lastIndex = 0;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
result: currentLine,
|
|
367
|
-
matched: false,
|
|
368
|
-
error: `pattern ${JSON.stringify(spec.pattern)} matches an empty string; use \`pre\`/\`post\`/\`splice\` to insert or replace whole lines, or use a non-empty pattern`,
|
|
369
|
-
};
|
|
341
|
+
// Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace` to
|
|
342
|
+
// insert the replacement at the match position rather than substitute. When that
|
|
343
|
+
// happens, fall through to the literal-substring fallback below — the model almost
|
|
344
|
+
// always meant the pattern literally (`()` is the parens, `^` is a caret, etc.).
|
|
345
|
+
if (!probe || probe[0].length > 0) {
|
|
346
|
+
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
370
347
|
}
|
|
371
|
-
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
372
348
|
}
|
|
373
349
|
// Fall back to literal substring match. Models frequently send sed patterns
|
|
374
350
|
// containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
|
|
@@ -417,8 +393,8 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
417
393
|
resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
|
|
418
394
|
}
|
|
419
395
|
if (entry.sed !== undefined) {
|
|
420
|
-
const spec =
|
|
421
|
-
resolved.push({ op: "sed_file", spec, expression:
|
|
396
|
+
const spec = parseSedSpec(entry.sed, editIndex);
|
|
397
|
+
resolved.push({ op: "sed_file", spec, expression: formatSedExpression(spec) });
|
|
422
398
|
}
|
|
423
399
|
return resolved;
|
|
424
400
|
}
|
|
@@ -448,8 +424,8 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
448
424
|
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
449
425
|
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
450
426
|
if (!spliceIsExplicitReplacement) {
|
|
451
|
-
const spec =
|
|
452
|
-
resolved.push({ op: "sed", pos: loc.pos, spec, expression:
|
|
427
|
+
const spec = parseSedSpec(entry.sed, editIndex);
|
|
428
|
+
resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
|
|
453
429
|
}
|
|
454
430
|
}
|
|
455
431
|
return resolved;
|
|
@@ -537,16 +513,18 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
537
513
|
}
|
|
538
514
|
|
|
539
515
|
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
540
|
-
// For each anchor line, at most one mutating op (splice/del).
|
|
541
|
-
//
|
|
516
|
+
// For each anchor line, at most one mutating op (splice/del). Multiple `sed`
|
|
517
|
+
// ops on the same line are allowed and applied sequentially. `pre`/`post`
|
|
518
|
+
// (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
542
519
|
const mutatingPerLine = new Map<number, string>();
|
|
543
520
|
for (const edit of edits) {
|
|
544
521
|
if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
|
|
545
522
|
const existing = mutatingPerLine.get(edit.pos.line);
|
|
546
523
|
if (existing) {
|
|
524
|
+
if (existing === "sed" && edit.op === "sed") continue;
|
|
547
525
|
throw new Error(
|
|
548
526
|
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
|
549
|
-
`At most one of splice/del
|
|
527
|
+
`At most one of splice/del is allowed per anchor.`,
|
|
550
528
|
);
|
|
551
529
|
}
|
|
552
530
|
mutatingPerLine.set(edit.pos.line, edit.op);
|
|
@@ -557,31 +535,6 @@ function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
|
557
535
|
// Apply
|
|
558
536
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
559
537
|
|
|
560
|
-
function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
|
|
561
|
-
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
|
|
562
|
-
if (!enabled) return;
|
|
563
|
-
for (const edit of edits) {
|
|
564
|
-
if (edit.op !== "splice" && edit.op !== "pre" && edit.op !== "post") continue;
|
|
565
|
-
if (edit.lines.length === 0) continue;
|
|
566
|
-
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
567
|
-
if (!hasEscapedTabs) continue;
|
|
568
|
-
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
|
569
|
-
if (hasRealTabs) continue;
|
|
570
|
-
let correctedCount = 0;
|
|
571
|
-
const corrected = edit.lines.map(line =>
|
|
572
|
-
line.replace(/^((?:\\t)+)/, escaped => {
|
|
573
|
-
correctedCount += escaped.length / 2;
|
|
574
|
-
return "\t".repeat(escaped.length / 2);
|
|
575
|
-
}),
|
|
576
|
-
);
|
|
577
|
-
if (correctedCount === 0) continue;
|
|
578
|
-
edit.lines = corrected;
|
|
579
|
-
warnings.push(
|
|
580
|
-
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
538
|
export interface AtomNoopEdit {
|
|
586
539
|
editIndex: number;
|
|
587
540
|
loc: string;
|
|
@@ -611,8 +564,19 @@ export function applyAtomEdits(
|
|
|
611
564
|
if (mismatches.length > 0) {
|
|
612
565
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
613
566
|
}
|
|
614
|
-
|
|
615
|
-
|
|
567
|
+
// When a `del` and a `sed`/`splice` target the same anchor (across separate edit
|
|
568
|
+
// entries), the `del` is almost always a hallucinated cleanup the model added on top
|
|
569
|
+
// of the real replacement. Drop the `del` silently so the replacement wins, matching
|
|
570
|
+
// the in-entry handling for `splice: []` paired with `sed`.
|
|
571
|
+
const replacedLines = new Set<number>();
|
|
572
|
+
for (const e of edits) {
|
|
573
|
+
if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
|
|
574
|
+
}
|
|
575
|
+
let effective = edits;
|
|
576
|
+
if (replacedLines.size > 0) {
|
|
577
|
+
effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
|
|
578
|
+
}
|
|
579
|
+
validateNoConflictingAnchorOps(effective);
|
|
616
580
|
|
|
617
581
|
const trackFirstChanged = (line: number) => {
|
|
618
582
|
if (firstChangedLine === undefined || line < firstChangedLine) {
|
|
@@ -629,7 +593,7 @@ export function applyAtomEdits(
|
|
|
629
593
|
const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
|
|
630
594
|
const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
|
|
631
595
|
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
632
|
-
|
|
596
|
+
effective.forEach((edit, idx) => {
|
|
633
597
|
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
634
598
|
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
635
599
|
else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
|
|
@@ -685,13 +649,14 @@ export function applyAtomEdits(
|
|
|
685
649
|
anchorMutated = true;
|
|
686
650
|
break;
|
|
687
651
|
case "sed": {
|
|
688
|
-
const
|
|
652
|
+
const input = replacementSet ? (replacement[0] ?? "") : currentLine;
|
|
653
|
+
const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
|
|
689
654
|
if (error) {
|
|
690
655
|
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
|
|
691
656
|
}
|
|
692
657
|
if (!matched) {
|
|
693
658
|
throw new Error(
|
|
694
|
-
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(
|
|
659
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(input)}`,
|
|
695
660
|
);
|
|
696
661
|
}
|
|
697
662
|
if (literalFallback) {
|
|
@@ -681,55 +681,6 @@ export function tryRebaseAnchor(
|
|
|
681
681
|
return found;
|
|
682
682
|
}
|
|
683
683
|
|
|
684
|
-
function isEscapedTabAutocorrectEnabled(): boolean {
|
|
685
|
-
switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
|
|
686
|
-
case "0":
|
|
687
|
-
return false;
|
|
688
|
-
case "1":
|
|
689
|
-
return true;
|
|
690
|
-
default:
|
|
691
|
-
return true;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
|
|
696
|
-
if (!isEscapedTabAutocorrectEnabled()) return;
|
|
697
|
-
for (const edit of edits) {
|
|
698
|
-
if (edit.lines.length === 0) continue;
|
|
699
|
-
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
700
|
-
if (!hasEscapedTabs) continue;
|
|
701
|
-
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
|
702
|
-
if (hasRealTabs) continue;
|
|
703
|
-
let correctedCount = 0;
|
|
704
|
-
const corrected = edit.lines.map(line =>
|
|
705
|
-
line.replace(/^((?:\\t)+)/, escaped => {
|
|
706
|
-
correctedCount += escaped.length / 2;
|
|
707
|
-
return "\t".repeat(escaped.length / 2);
|
|
708
|
-
}),
|
|
709
|
-
);
|
|
710
|
-
if (correctedCount === 0) continue;
|
|
711
|
-
edit.lines = corrected;
|
|
712
|
-
warnings.push(
|
|
713
|
-
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void {
|
|
719
|
-
for (const edit of edits) {
|
|
720
|
-
if (edit.lines.length === 0) continue;
|
|
721
|
-
if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue;
|
|
722
|
-
warnings.push(
|
|
723
|
-
`Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`,
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function runHashlinePreflightSanitizers(edits: HashlineEdit[], warnings: string[]): void {
|
|
729
|
-
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
730
|
-
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
684
|
function ensureHashlineEditHasContent(edit: HashlineEdit): void {
|
|
734
685
|
if (edit.lines.length === 0) {
|
|
735
686
|
edit.lines = [""];
|
|
@@ -1026,7 +977,6 @@ export function applyHashlineEdits(
|
|
|
1026
977
|
if (mismatches.length > 0) {
|
|
1027
978
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
1028
979
|
}
|
|
1029
|
-
runHashlinePreflightSanitizers(edits, warnings);
|
|
1030
980
|
for (const edit of edits) {
|
|
1031
981
|
collectBoundaryDuplicationWarning(edit, originalFileLines, warnings);
|
|
1032
982
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Edit tool renderer and LSP batching helpers.
|
|
3
3
|
*/
|
|
4
|
+
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
4
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
6
|
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
6
7
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
@@ -157,32 +158,16 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
function decodePartialJsonStringFragment(fragment: string): string {
|
|
160
|
-
|
|
161
|
+
// Trim a trailing partial escape so JSON.parse sees a well-formed string.
|
|
162
|
+
let text = fragment.replace(/\\u[0-9a-fA-F]{0,3}$/, "");
|
|
161
163
|
const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
|
|
162
|
-
if (trailingBackslashes % 2 === 1)
|
|
163
|
-
text = text.slice(0, -1);
|
|
164
|
-
}
|
|
164
|
+
if (trailingBackslashes % 2 === 1) text = text.slice(0, -1);
|
|
165
165
|
try {
|
|
166
166
|
return JSON.parse(`"${text}"`) as string;
|
|
167
167
|
} catch {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
switch (ch) {
|
|
172
|
-
case "b":
|
|
173
|
-
return "\b";
|
|
174
|
-
case "f":
|
|
175
|
-
return "\f";
|
|
176
|
-
case "n":
|
|
177
|
-
return "\n";
|
|
178
|
-
case "r":
|
|
179
|
-
return "\r";
|
|
180
|
-
case "t":
|
|
181
|
-
return "\t";
|
|
182
|
-
default:
|
|
183
|
-
return ch;
|
|
184
|
-
}
|
|
185
|
-
});
|
|
168
|
+
// Streaming fragment isn't a valid JSON string yet; surface it raw rather
|
|
169
|
+
// than ad-hoc unescaping that mishandles surrogates and partial escapes.
|
|
170
|
+
return text;
|
|
186
171
|
}
|
|
187
172
|
}
|
|
188
173
|
|
|
@@ -243,11 +228,11 @@ function formatEditDescription(
|
|
|
243
228
|
};
|
|
244
229
|
}
|
|
245
230
|
|
|
246
|
-
function renderPlainTextPreview(text: string, uiTheme: Theme): string {
|
|
247
|
-
const previewLines = text.split("\n");
|
|
231
|
+
function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
|
|
232
|
+
const previewLines = sanitizeText(text).split("\n");
|
|
248
233
|
let preview = "\n\n";
|
|
249
234
|
for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) {
|
|
250
|
-
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`;
|
|
235
|
+
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`;
|
|
251
236
|
}
|
|
252
237
|
if (previewLines.length > CALL_TEXT_PREVIEW_LINES) {
|
|
253
238
|
preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`);
|
|
@@ -284,7 +269,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
|
|
|
284
269
|
if (!preview.diff && !preview.error) continue;
|
|
285
270
|
const header = uiTheme.fg("dim", `\n\n\u2500\u2500 ${shortenPath(preview.path)} \u2500\u2500`);
|
|
286
271
|
if (preview.error) {
|
|
287
|
-
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
|
|
272
|
+
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`);
|
|
288
273
|
continue;
|
|
289
274
|
}
|
|
290
275
|
if (preview.diff) {
|
|
@@ -311,10 +296,10 @@ function getCallPreview(
|
|
|
311
296
|
return formatStreamingDiff(args.diff, rawPath, uiTheme);
|
|
312
297
|
}
|
|
313
298
|
if (args.diff) {
|
|
314
|
-
return renderPlainTextPreview(args.diff, uiTheme);
|
|
299
|
+
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
|
|
315
300
|
}
|
|
316
301
|
if (args.newText || args.patch) {
|
|
317
|
-
return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme);
|
|
302
|
+
return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme, rawPath);
|
|
318
303
|
}
|
|
319
304
|
return "";
|
|
320
305
|
}
|
|
@@ -438,7 +423,7 @@ export const editToolRenderer = {
|
|
|
438
423
|
}
|
|
439
424
|
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
|
|
440
425
|
if (applyPatchSummary?.error) {
|
|
441
|
-
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
|
|
426
|
+
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`;
|
|
442
427
|
}
|
|
443
428
|
|
|
444
429
|
return new Text(text, 0, 0);
|
|
@@ -529,13 +514,13 @@ function renderSingleFileResult(
|
|
|
529
514
|
|
|
530
515
|
if (isError) {
|
|
531
516
|
if (errorText) {
|
|
532
|
-
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
|
|
517
|
+
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText, rawPath))}`;
|
|
533
518
|
}
|
|
534
519
|
} else if (details?.diff) {
|
|
535
520
|
text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
|
536
521
|
} else if (editDiffPreview) {
|
|
537
522
|
if ("error" in editDiffPreview) {
|
|
538
|
-
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
|
|
523
|
+
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath))}`;
|
|
539
524
|
} else if (editDiffPreview.diff) {
|
|
540
525
|
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
|
541
526
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
1
2
|
import { getIndentation } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import * as Diff from "diff";
|
|
3
4
|
import { theme } from "../../modes/theme/theme";
|
|
@@ -15,7 +16,7 @@ const DIM_OFF = "\x1b[22m";
|
|
|
15
16
|
*/
|
|
16
17
|
function visualizeIndent(text: string, filePath?: string): string {
|
|
17
18
|
const match = text.match(/^([ \t]+)/);
|
|
18
|
-
if (!match) return replaceTabs(text);
|
|
19
|
+
if (!match) return replaceTabs(text, filePath);
|
|
19
20
|
const indent = match[1];
|
|
20
21
|
const rest = text.slice(indent.length);
|
|
21
22
|
const tabWidth = getIndentation(filePath);
|
|
@@ -30,7 +31,7 @@ function visualizeIndent(text: string, filePath?: string): string {
|
|
|
30
31
|
visible += `${DIM}·${DIM_OFF}`;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
return `${visible}${replaceTabs(rest)}`;
|
|
34
|
+
return `${visible}${replaceTabs(rest, filePath)}`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -106,7 +107,7 @@ export interface RenderDiffOptions {
|
|
|
106
107
|
* - Added lines: green, with inverse on changed tokens
|
|
107
108
|
*/
|
|
108
109
|
export function renderDiff(diffText: string, options: RenderDiffOptions = {}): string {
|
|
109
|
-
const lines = diffText.split("\n");
|
|
110
|
+
const lines = sanitizeText(diffText).split("\n");
|
|
110
111
|
const result: string[] = [];
|
|
111
112
|
const parsedLines = lines.map(parseDiffLine);
|
|
112
113
|
const lineNumberWidth = parsedLines.reduce((width, parsed) => {
|
|
@@ -138,7 +139,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
|
|
|
138
139
|
|
|
139
140
|
if (!parsed) {
|
|
140
141
|
prevLineNum = "";
|
|
141
|
-
result.push(theme.fg("toolDiffContext", line));
|
|
142
|
+
result.push(theme.fg("toolDiffContext", replaceTabs(line, options.filePath)));
|
|
142
143
|
i++;
|
|
143
144
|
continue;
|
|
144
145
|
}
|
|
@@ -388,10 +388,12 @@ export class StatusLineComponent implements Component {
|
|
|
388
388
|
|
|
389
389
|
// Collect visible segment contents
|
|
390
390
|
const leftParts: string[] = [];
|
|
391
|
+
const leftSegIds: StatusLineSegmentId[] = [];
|
|
391
392
|
for (const segId of effectiveSettings.leftSegments) {
|
|
392
393
|
const rendered = renderSegment(segId, ctx);
|
|
393
394
|
if (rendered.visible && rendered.content) {
|
|
394
395
|
leftParts.push(rendered.content);
|
|
396
|
+
leftSegIds.push(segId);
|
|
395
397
|
}
|
|
396
398
|
}
|
|
397
399
|
|
|
@@ -434,8 +436,42 @@ export class StatusLineComponent implements Component {
|
|
|
434
436
|
right.pop();
|
|
435
437
|
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
|
436
438
|
}
|
|
439
|
+
// Shrink path before dropping left segments — path is the only elastic segment
|
|
440
|
+
const pathIdx = leftSegIds.indexOf("path");
|
|
441
|
+
if (pathIdx >= 0 && totalWidth() > topFillWidth) {
|
|
442
|
+
const overflow = totalWidth() - topFillWidth;
|
|
443
|
+
const currentPathVW = visibleWidth(left[pathIdx]);
|
|
444
|
+
const minPathVW = 8; // icon + ellipsis + a few chars
|
|
445
|
+
const shrinkable = currentPathVW - minPathVW;
|
|
446
|
+
if (shrinkable > 0) {
|
|
447
|
+
const shrinkBy = Math.min(shrinkable, overflow);
|
|
448
|
+
const currentMaxLen = ctx.options.path?.maxLength ?? 40;
|
|
449
|
+
let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
|
450
|
+
const pathCtx = (maxLen: number): SegmentContext => ({
|
|
451
|
+
...ctx,
|
|
452
|
+
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
|
453
|
+
});
|
|
454
|
+
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
|
455
|
+
if (reRendered.visible && reRendered.content) {
|
|
456
|
+
// maxLength governs path text, not icon prefix; iterate to compensate
|
|
457
|
+
for (let i = 0; i < 8; i++) {
|
|
458
|
+
const saved = currentPathVW - visibleWidth(reRendered.content);
|
|
459
|
+
if (saved >= shrinkBy) break;
|
|
460
|
+
const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
|
|
461
|
+
if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
|
|
462
|
+
newMaxLen = nextMaxLen;
|
|
463
|
+
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
|
464
|
+
if (!adjusted.visible || !adjusted.content) break;
|
|
465
|
+
reRendered = adjusted;
|
|
466
|
+
}
|
|
467
|
+
left[pathIdx] = reRendered.content;
|
|
468
|
+
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
437
472
|
while (totalWidth() > topFillWidth && left.length > 0) {
|
|
438
473
|
left.pop();
|
|
474
|
+
leftSegIds.pop();
|
|
439
475
|
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
|
440
476
|
}
|
|
441
477
|
}
|
|
@@ -172,12 +172,18 @@ export class EventController {
|
|
|
172
172
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
173
173
|
|
|
174
174
|
this.#resetReadGroup();
|
|
175
|
-
|
|
175
|
+
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
176
|
+
if (!wasOptimistic) {
|
|
176
177
|
this.ctx.addMessageToChat(event.message);
|
|
177
178
|
}
|
|
178
179
|
this.ctx.optimisticUserMessageSignature = undefined;
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
// Clear the editor only when the submission did not originate from this
|
|
182
|
+
// session's optimistic flow (which already cleared the editor at submit
|
|
183
|
+
// time). Clearing here on the optimistic path would race with the user
|
|
184
|
+
// typing the next prompt while the previous large redraw lands and erase
|
|
185
|
+
// their in-progress draft (#783).
|
|
186
|
+
if (!event.message.synthetic && !wasOptimistic) {
|
|
181
187
|
this.ctx.editor.setText("");
|
|
182
188
|
this.ctx.updatePendingMessagesDisplay();
|
|
183
189
|
}
|
|
@@ -263,8 +269,21 @@ export class EventController {
|
|
|
263
269
|
for (const content of this.ctx.streamingMessage.content) {
|
|
264
270
|
if (content.type !== "toolCall") continue;
|
|
265
271
|
const args = content.arguments;
|
|
266
|
-
if (!args || typeof args !== "object"
|
|
267
|
-
|
|
272
|
+
if (!args || typeof args !== "object") continue;
|
|
273
|
+
if (INTENT_FIELD in args) {
|
|
274
|
+
this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const tool = this.ctx.session.getToolByName(content.name);
|
|
278
|
+
if (typeof tool?.intent !== "function") continue;
|
|
279
|
+
try {
|
|
280
|
+
const derived = tool.intent(args as never)?.trim();
|
|
281
|
+
if (derived) {
|
|
282
|
+
this.#updateWorkingMessageFromIntent(derived);
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// intent function must never break the UI
|
|
286
|
+
}
|
|
268
287
|
}
|
|
269
288
|
|
|
270
289
|
this.ctx.ui.requestRender();
|
|
@@ -168,7 +168,7 @@ Tools:
|
|
|
168
168
|
|
|
169
169
|
{{#if intentTracing}}
|
|
170
170
|
<intent-field>
|
|
171
|
-
|
|
171
|
+
Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
|
|
172
172
|
</intent-field>
|
|
173
173
|
{{/if}}
|
|
174
174
|
|
|
@@ -14,10 +14,12 @@ Verbs:
|
|
|
14
14
|
- `splice: […]`: lines are spliced in at the anchor.
|
|
15
15
|
- `pre: […]`: prepend before the anchor (or at BOF if `loc=$`)
|
|
16
16
|
- `post: […]`: append after the anchor (or at EOF if `loc=$`)
|
|
17
|
-
- `sed:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
- `sed: { pat, rep, g?, F? }` — structured find/replace on the anchor line. **Prefer this over `splice` for token-level changes**
|
|
18
|
+
- `pat`: pattern to find (regex by default)
|
|
19
|
+
- `rep`: replacement (regex back-refs like `$1`, `$&` available)
|
|
20
|
+
- `g`: global — replace every occurrence (default `false`; pass `true` to replace all)
|
|
21
|
+
- `F`: literal — treat `pat` as a literal substring (no regex). Use this whenever `pat` contains `||`, `.`, `(`, `?`, `\`, etc. you mean literally.
|
|
22
|
+
You **MUST** keep `pat` as short as possible.
|
|
21
23
|
|
|
22
24
|
Combination rules:
|
|
23
25
|
- On a single-anchor `loc`, you may combine `pre`, `splice`, and `post` in the same entry.
|
|
@@ -55,12 +57,15 @@ All examples below reference the same file:
|
|
|
55
57
|
`{path:"a.ts",edits:[{loc:{{href 3 "function beta(x) {"}},pre:["function gamma() {","\tvalidate();","}",""]}]}`
|
|
56
58
|
|
|
57
59
|
# Substitute one token with `sed` (regex) — preferred for token-level edits
|
|
58
|
-
Use the smallest
|
|
59
|
-
`{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"
|
|
60
|
+
Use the smallest `pat` that uniquely identifies the change.
|
|
61
|
+
`{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:{pat:"\\|\\|",rep:"??"}}]}`
|
|
60
62
|
|
|
61
|
-
# Substitute
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
# Substitute literal text — set `F:true` so `pat` is not parsed as regex
|
|
64
|
+
`{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:{pat:"data",rep:"input",F:true}}]}`
|
|
65
|
+
|
|
66
|
+
# Comment out a line by capturing the whole content with a regex
|
|
67
|
+
Use `$&` (the entire match) inside `rep` to keep the original text and prepend `// `.
|
|
68
|
+
`{path:"a.ts",edits:[{loc:{{href 7 "\treturn null;"}},sed:{pat:".+",rep:"// $&"}}]}`
|
|
64
69
|
|
|
65
70
|
# Prepend / append at file edges
|
|
66
71
|
`{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
|
|
@@ -74,7 +79,7 @@ Use the `F` flag to disable regex; the delimiter can be any non-alphanumeric cha
|
|
|
74
79
|
The 2nd array element matches existing line 5, which is **not** overwritten, it shifts, so return statement ends up duplicated.
|
|
75
80
|
|
|
76
81
|
# RIGHT: split into separate edits
|
|
77
|
-
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},sed:"
|
|
82
|
+
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},sed:{pat:"x",rep:"x && ready",g:false}},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},post:["\t\t//unreachable"]}]}`
|
|
78
83
|
OR
|
|
79
84
|
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {"]},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},splice:["\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
|
|
80
85
|
</examples>
|
|
@@ -88,7 +93,7 @@ OR
|
|
|
88
93
|
- `splice: []` deletes the anchored line. `splice:[""]` preserves a blank line.
|
|
89
94
|
- Within a single request you may submit edits in any order — the runtime applies them bottom-up so they don't shift each other. After any request that mutates a file, anchors below the mutation are stale on disk; re-read before issuing more edits to that file.
|
|
90
95
|
- `splice` operations target the current file content only. Do not try to reference old line text after the file has changed.
|
|
91
|
-
- For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `
|
|
96
|
+
- For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `pat` that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. When `pat` contains regex metacharacters you mean literally (e.g. `||`, `.`, `(`, `?`, `\`), set `F:true` to disable regex. `g` is `false` by default — pass `g:true` to replace every occurrence. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
|
|
92
97
|
- When you do use `splice`, re-read the anchored line first and copy it verbatim, changing only the required token(s). Anchor identity does not verify line content, so a hallucinated replacement will silently corrupt the file.
|
|
93
98
|
- Anchors are pin points, not region markers. One anchor pins exactly one line. If your change touches N distinct source lines, that is N edits with N anchors — not one big `splice` array intended to cover the whole region. `splice` cannot "replace lines 4 through 7"; it can only splice content in at one anchor.
|
|
94
99
|
- You **MUST NOT** include lines in `splice`/`pre`/`post` that already exist immediately adjacent to the anchor in the current file. `splice` does not overwrite the lines below — they shift down — so any neighbor you re-type in your array becomes a duplicate. If your intended replacement contains content that is already on neighboring source lines, split into multiple edits at each real change site instead of one fat `splice`.
|
|
@@ -2970,6 +2970,30 @@ export class AgentSession {
|
|
|
2970
2970
|
attribution: "user",
|
|
2971
2971
|
timestamp: Date.now(),
|
|
2972
2972
|
});
|
|
2973
|
+
// When fully idle AND the session is in a resumable assistant-ended state,
|
|
2974
|
+
// schedule an immediate continue so the queued follow-up is delivered
|
|
2975
|
+
// without waiting for the next user turn. We gate on isStreaming (model
|
|
2976
|
+
// actively producing), isRetrying (auto-retry backoff is sleeping between
|
|
2977
|
+
// attempts, #retryPromise set), and the last message being assistant —
|
|
2978
|
+
// agent.continue() only dequeues follow-ups from an assistant-ended state;
|
|
2979
|
+
// resuming from user/toolResult state runs an extra model call on the
|
|
2980
|
+
// stale prompt before draining the queue.
|
|
2981
|
+
if (this.#canAutoContinueForFollowUp()) {
|
|
2982
|
+
this.#scheduleAgentContinue({
|
|
2983
|
+
shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
/**
|
|
2989
|
+
* Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
|
|
2990
|
+
*/
|
|
2991
|
+
#canAutoContinueForFollowUp(): boolean {
|
|
2992
|
+
if (this.isStreaming) return false;
|
|
2993
|
+
if (this.isRetrying) return false;
|
|
2994
|
+
const messages = this.agent.state.messages;
|
|
2995
|
+
const last = messages[messages.length - 1];
|
|
2996
|
+
return last?.role === "assistant";
|
|
2973
2997
|
}
|
|
2974
2998
|
|
|
2975
2999
|
queueDeferredMessage(message: CustomMessage): void {
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -52,6 +52,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
|
|
|
52
52
|
readonly description: string;
|
|
53
53
|
readonly parameters = checkpointSchema;
|
|
54
54
|
readonly strict = true;
|
|
55
|
+
readonly intent = (args: Partial<CheckpointParams>) => args.goal;
|
|
55
56
|
|
|
56
57
|
constructor(private readonly session: ToolSession) {
|
|
57
58
|
this.description = prompt.render(checkpointDescription);
|
|
@@ -94,6 +95,7 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
|
|
|
94
95
|
readonly description: string;
|
|
95
96
|
readonly parameters = rewindSchema;
|
|
96
97
|
readonly strict = true;
|
|
98
|
+
readonly intent = (): string => "Rewinding to checkpoint";
|
|
97
99
|
|
|
98
100
|
constructor(private readonly session: ToolSession) {
|
|
99
101
|
this.description = prompt.render(rewindDescription);
|
|
@@ -46,6 +46,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
|
|
|
46
46
|
readonly parameters = exitPlanModeSchema;
|
|
47
47
|
readonly strict = true;
|
|
48
48
|
readonly concurrency = "exclusive";
|
|
49
|
+
readonly intent = (): string => "Exiting plan mode";
|
|
49
50
|
|
|
50
51
|
constructor(private readonly session: ToolSession) {
|
|
51
52
|
this.description = prompt.render(exitPlanModeDescription);
|
package/src/tools/read.ts
CHANGED
|
@@ -456,6 +456,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
456
456
|
readonly parameters = readSchema;
|
|
457
457
|
readonly nonAbortable = true;
|
|
458
458
|
readonly strict = true;
|
|
459
|
+
readonly intent = (args: Partial<ReadParams>): string => {
|
|
460
|
+
const p = typeof args.path === "string" ? args.path.trim() : "";
|
|
461
|
+
if (!p) return "Reading";
|
|
462
|
+
const isUrl = /^(https?|ftp):\/\//i.test(p);
|
|
463
|
+
return isUrl ? `Fetching ${p}` : `Reading ${p}`;
|
|
464
|
+
};
|
|
459
465
|
|
|
460
466
|
readonly #autoResizeImages: boolean;
|
|
461
467
|
readonly #defaultLimit: number;
|
|
@@ -60,6 +60,7 @@ export function createReportToolIssueTool(session: ToolSession): AgentTool {
|
|
|
60
60
|
strict: false,
|
|
61
61
|
description: "Report unexpected tool behavior for automated QA tracking.",
|
|
62
62
|
parameters: ReportToolIssueParams,
|
|
63
|
+
intent: "omit",
|
|
63
64
|
async execute(_toolCallId, rawParams) {
|
|
64
65
|
try {
|
|
65
66
|
const params = rawParams as { tool: string; report: string };
|
package/src/tools/resolve.ts
CHANGED
|
@@ -110,6 +110,8 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
|
|
|
110
110
|
readonly description: string;
|
|
111
111
|
readonly parameters = resolveSchema;
|
|
112
112
|
readonly strict = true;
|
|
113
|
+
readonly intent = (args: Partial<ResolveParams>) =>
|
|
114
|
+
args.action === "discard" ? "Discarding pending action" : "Applying pending action";
|
|
113
115
|
|
|
114
116
|
constructor(private readonly session: ToolSession) {
|
|
115
117
|
this.description = prompt.render(resolveDescription);
|
package/src/tools/review.ts
CHANGED
|
@@ -135,6 +135,7 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
|
|
|
135
135
|
label: "Report Finding",
|
|
136
136
|
description: "Report a code review finding. Use this for each issue found. Call yield when done.",
|
|
137
137
|
parameters: ReportFindingParams,
|
|
138
|
+
intent: "omit",
|
|
138
139
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
139
140
|
const { title, body, priority, confidence, file_path, line_start, line_end } = params;
|
|
140
141
|
const location = `${file_path}:${line_start}${line_end !== line_start ? `-${line_end}` : ""}`;
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -583,6 +583,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
|
|
|
583
583
|
readonly parameters = todoWriteSchema;
|
|
584
584
|
readonly concurrency = "exclusive";
|
|
585
585
|
readonly strict = true;
|
|
586
|
+
readonly intent = "omit" as const;
|
|
586
587
|
|
|
587
588
|
constructor(private readonly session: ToolSession) {
|
|
588
589
|
this.description = prompt.render(todoWriteDescription);
|
package/src/tools/yield.ts
CHANGED
|
@@ -49,6 +49,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
|
49
49
|
"The `data`/`error` wrapper is required — do not put your output directly in `result`.";
|
|
50
50
|
readonly parameters: TSchema;
|
|
51
51
|
strict = true;
|
|
52
|
+
readonly intent = "omit" as const;
|
|
52
53
|
lenientArgValidation = true;
|
|
53
54
|
|
|
54
55
|
readonly #validate?: ValidateFunction;
|
package/src/utils/tool-choice.ts
CHANGED
|
@@ -20,7 +20,12 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
|
|
|
20
20
|
return { type: "function", name: toolName };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
if (
|
|
23
|
+
if (
|
|
24
|
+
model.api === "google-generative-ai" ||
|
|
25
|
+
model.api === "google-gemini-cli" ||
|
|
26
|
+
model.api === "google-vertex" ||
|
|
27
|
+
model.api === "ollama-chat"
|
|
28
|
+
) {
|
|
24
29
|
return "required";
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -14,7 +14,7 @@ import { SearchProvider } from "./base";
|
|
|
14
14
|
import { findCredential, isApiKeyAvailable } from "./utils";
|
|
15
15
|
|
|
16
16
|
const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
|
|
17
|
-
const ZAI_TOOL_NAME = "
|
|
17
|
+
const ZAI_TOOL_NAME = "web_search_prime";
|
|
18
18
|
const DEFAULT_NUM_RESULTS = 10;
|
|
19
19
|
|
|
20
20
|
export interface ZaiSearchParams {
|