@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 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.0",
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.0",
50
- "@oh-my-pi/pi-agent-core": "14.5.0",
51
- "@oh-my-pi/pi-ai": "14.5.0",
52
- "@oh-my-pi/pi-natives": "14.5.0",
53
- "@oh-my-pi/pi-tui": "14.5.0",
54
- "@oh-my-pi/pi-utils": "14.5.0",
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.13",
72
+ "@types/bun": "^1.3",
73
73
  "@types/turndown": "5.0.6"
74
74
  },
75
75
  "engines": {
@@ -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: "s/foo/bar/" } // sed on every line
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.String({
70
- description: "sed-style substitution applied to the anchored line",
71
- examples: ["s/foo/bar/", "s|api|API|g", "s/<pat>/<rep>/F"],
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 parseSedExpression(raw: string, editIndex: number): SedSpec {
262
- if (typeof raw !== "string" || raw.length < 3) {
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
- // Tolerate a missing leading `s`: models occasionally emit `/foo/bar/` directly.
268
- // As long as the first character is a valid delimiter, treat the expression as
269
- // if `s` was prepended.
270
- let bodyStart = 0;
271
- if (raw[0] === "s") {
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
- const delim = raw[bodyStart]!;
275
- if (/[\sA-Za-z0-9\\]/.test(delim)) {
277
+ if (pat.includes("\n")) {
276
278
  throw new Error(
277
- `Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
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
- const parts: [string, string] = ["", ""];
281
- let bucket: 0 | 1 = 0;
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 flagsStr = raw.slice(i);
308
- let global = false;
309
- let ignoreCase = false;
310
- let literal = false;
311
- for (const f of flagsStr) {
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
- if (parts[0] === "") {
322
- throw new Error(`Edit ${editIndex}: sed expression has empty pattern.`);
323
- }
324
- return { pattern: parts[0], replacement: parts[1], global, ignoreCase, literal };
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
- if (probe && probe[0].length === 0) {
361
- // Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace`
362
- // to insert the replacement at the match position rather than substitute,
363
- // which is almost never what models intend. Reject with a pointer to the
364
- // dedicated insertion verbs.
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 = parseSedExpression(entry.sed, editIndex);
421
- resolved.push({ op: "sed_file", spec, expression: entry.sed });
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 = parseSedExpression(entry.sed, editIndex);
452
- resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
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
- // `pre`/`post` (insert ops) may coexist with them they don't mutate the anchor line.
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/sed is allowed per anchor.`,
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
- validateNoConflictingAnchorOps(edits);
615
- maybeAutocorrectEscapedTabIndentation(edits, warnings);
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
- edits.forEach((edit, idx) => {
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 { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
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(currentLine)}`,
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
  }
@@ -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
- let text = fragment;
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
- return text
169
- .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
170
- .replace(/\\(["\\/bfnrt])/g, (_, ch: string) => {
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
- if (this.ctx.optimisticUserMessageSignature !== signature) {
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
- if (!event.message.synthetic) {
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" || !(INTENT_FIELD in args)) continue;
267
- this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
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
- Every tool has a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
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: "s/foo/bar/"` — sed-style substitution applied to the anchor line. **Prefer this over `splice` for token-level changes**
18
- Flags: `g` (all occurrences), `i` (case-insensitive), `F` (literal).
19
- Delimiter is whatever character follows `s`.
20
- You **MUST** keep the pattern as short as possible.
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 pattern that uniquely identifies the change.
59
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s/\\|\\|/??/"}]}`
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 every occurrence with `sed` (literal/fixed-string)
62
- Use the `F` flag to disable regex; the delimiter can be any non-alphanumeric char.
63
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s|data|input|gF"}]}`
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:"s/x/x \\&\\& ready/"},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},post:["\t\t//unreachable"]}]}`
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 `sed` pattern that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
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 {
@@ -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 };
@@ -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);
@@ -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}` : ""}`;
@@ -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);
@@ -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;
@@ -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 (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
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 = "webSearchPrime";
17
+ const ZAI_TOOL_NAME = "web_search_prime";
18
18
  const DEFAULT_NUM_RESULTS = 10;
19
19
 
20
20
  export interface ZaiSearchParams {