@oh-my-pi/pi-coding-agent 14.1.2 → 14.2.1

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/package.json +8 -8
  3. package/scripts/build-binary.ts +61 -0
  4. package/src/autoresearch/helpers.ts +10 -0
  5. package/src/autoresearch/index.ts +1 -11
  6. package/src/autoresearch/tools/init-experiment.ts +1 -10
  7. package/src/autoresearch/tools/log-experiment.ts +1 -11
  8. package/src/autoresearch/tools/run-experiment.ts +1 -10
  9. package/src/bun-imports.d.ts +6 -0
  10. package/src/cli/plugin-cli.ts +23 -45
  11. package/src/commit/agentic/tools/propose-commit.ts +1 -14
  12. package/src/commit/agentic/tools/split-commit.ts +1 -15
  13. package/src/commit/utils.ts +15 -1
  14. package/src/config/model-registry.ts +3 -3
  15. package/src/config/prompt-templates.ts +4 -12
  16. package/src/config/settings-schema.ts +27 -2
  17. package/src/config/settings.ts +1 -1
  18. package/src/dap/session.ts +8 -2
  19. package/src/discovery/claude-plugins.ts +61 -6
  20. package/src/discovery/codex.ts +2 -15
  21. package/src/discovery/gemini.ts +2 -15
  22. package/src/discovery/helpers.ts +40 -1
  23. package/src/discovery/opencode.ts +2 -15
  24. package/src/edit/apply-patch/index.ts +87 -0
  25. package/src/edit/apply-patch/parser.ts +174 -0
  26. package/src/edit/diff.ts +3 -14
  27. package/src/edit/index.ts +67 -3
  28. package/src/edit/modes/apply-patch.lark +19 -0
  29. package/src/edit/modes/apply-patch.ts +63 -0
  30. package/src/edit/modes/chunk.ts +6 -2
  31. package/src/edit/modes/hashline.ts +3 -3
  32. package/src/edit/modes/replace.ts +2 -13
  33. package/src/edit/read-file.ts +18 -0
  34. package/src/edit/renderer.ts +61 -33
  35. package/src/extensibility/extensions/compact-handler.ts +40 -0
  36. package/src/extensibility/extensions/runner.ts +11 -29
  37. package/src/extensibility/utils.ts +7 -1
  38. package/src/internal-urls/docs-index.generated.ts +9 -2
  39. package/src/lsp/client.ts +14 -5
  40. package/src/lsp/index.ts +53 -10
  41. package/src/lsp/render.ts +14 -2
  42. package/src/lsp/types.ts +2 -0
  43. package/src/main.ts +1 -0
  44. package/src/mcp/manager.ts +29 -48
  45. package/src/memories/index.ts +7 -1
  46. package/src/modes/acp/acp-agent.ts +3 -16
  47. package/src/modes/components/model-selector.ts +15 -24
  48. package/src/modes/components/plugin-settings.ts +16 -5
  49. package/src/modes/components/read-tool-group.ts +92 -9
  50. package/src/modes/components/settings-defs.ts +18 -0
  51. package/src/modes/components/settings-selector.ts +2 -6
  52. package/src/modes/components/tool-execution.ts +61 -28
  53. package/src/modes/controllers/event-controller.ts +3 -1
  54. package/src/modes/controllers/extension-ui-controller.ts +99 -150
  55. package/src/modes/controllers/selector-controller.ts +3 -12
  56. package/src/modes/interactive-mode.ts +4 -2
  57. package/src/modes/print-mode.ts +4 -22
  58. package/src/modes/rpc/rpc-mode.ts +18 -38
  59. package/src/modes/shared.ts +10 -1
  60. package/src/modes/utils/ui-helpers.ts +6 -2
  61. package/src/plan-mode/approved-plan.ts +5 -4
  62. package/src/prompts/system/subagent-system-prompt.md +4 -4
  63. package/src/prompts/system/subagent-user-prompt.md +2 -2
  64. package/src/prompts/system/system-prompt.md +208 -243
  65. package/src/prompts/tools/apply-patch.md +67 -0
  66. package/src/prompts/tools/ast-edit.md +18 -23
  67. package/src/prompts/tools/ast-grep.md +25 -32
  68. package/src/prompts/tools/bash.md +11 -23
  69. package/src/prompts/tools/debug.md +8 -22
  70. package/src/prompts/tools/find.md +0 -4
  71. package/src/prompts/tools/grep.md +3 -5
  72. package/src/prompts/tools/hashline.md +16 -10
  73. package/src/prompts/tools/python.md +10 -14
  74. package/src/prompts/tools/read.md +17 -24
  75. package/src/prompts/tools/task.md +57 -21
  76. package/src/prompts/tools/todo-write.md +45 -67
  77. package/src/session/agent-session.ts +4 -4
  78. package/src/session/session-manager.ts +15 -7
  79. package/src/session/streaming-output.ts +24 -0
  80. package/src/slash-commands/builtin-registry.ts +3 -14
  81. package/src/task/executor.ts +13 -34
  82. package/src/task/index.ts +82 -18
  83. package/src/task/simple-mode.ts +27 -0
  84. package/src/task/template.ts +17 -3
  85. package/src/task/types.ts +77 -30
  86. package/src/tools/ask.ts +2 -4
  87. package/src/tools/ast-edit.ts +41 -17
  88. package/src/tools/ast-grep.ts +8 -27
  89. package/src/tools/bash-skill-urls.ts +9 -7
  90. package/src/tools/bash.ts +66 -24
  91. package/src/tools/browser.ts +1 -1
  92. package/src/tools/fetch.ts +1 -14
  93. package/src/tools/file-recorder.ts +35 -0
  94. package/src/tools/find.ts +25 -29
  95. package/src/tools/gh-format.ts +12 -0
  96. package/src/tools/gh-renderer.ts +1 -8
  97. package/src/tools/gh.ts +6 -13
  98. package/src/tools/grep.ts +103 -59
  99. package/src/tools/jtd-to-json-schema.ts +16 -0
  100. package/src/tools/match-line-format.ts +20 -0
  101. package/src/tools/path-utils.ts +61 -5
  102. package/src/tools/plan-mode-guard.ts +6 -5
  103. package/src/tools/python.ts +1 -1
  104. package/src/tools/read.ts +1 -1
  105. package/src/tools/render-utils.ts +38 -6
  106. package/src/tools/renderers.ts +1 -0
  107. package/src/tools/resolve.ts +12 -3
  108. package/src/tools/ssh.ts +3 -11
  109. package/src/tools/submit-result.ts +1 -13
  110. package/src/tools/todo-write.ts +137 -103
  111. package/src/tools/vim.ts +1 -1
  112. package/src/tools/write.ts +2 -23
  113. package/src/tui/code-cell.ts +12 -7
  114. package/src/utils/edit-mode.ts +3 -2
  115. package/src/utils/git.ts +1 -1
  116. package/src/vim/engine.ts +41 -58
  117. package/src/web/scrapers/crates-io.ts +1 -14
  118. package/src/web/scrapers/types.ts +13 -0
  119. package/src/web/search/providers/base.ts +13 -0
  120. package/src/web/search/providers/brave.ts +2 -5
  121. package/src/web/search/providers/codex.ts +20 -24
  122. package/src/web/search/providers/gemini.ts +39 -1
  123. package/src/web/search/providers/jina.ts +2 -5
  124. package/src/web/search/providers/kagi.ts +3 -8
  125. package/src/web/search/providers/kimi.ts +3 -7
  126. package/src/web/search/providers/parallel.ts +3 -8
  127. package/src/web/search/providers/synthetic.ts +3 -7
  128. package/src/web/search/providers/tavily.ts +15 -11
  129. package/src/web/search/providers/utils.ts +36 -0
  130. package/src/web/search/providers/zai.ts +3 -7
package/src/tools/grep.ts CHANGED
@@ -6,7 +6,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
- import { computeLineHash } from "../edit/line-hash";
10
9
  import { type ChunkedGrepMatch, describeChunkedGrepMatch } from "../edit/modes/chunk";
11
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
11
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -16,6 +15,8 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
16
15
  import { resolveEditMode } from "../utils/edit-mode";
17
16
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
18
17
  import type { ToolSession } from ".";
18
+ import { createFileRecorder } from "./file-recorder";
19
+ import { formatMatchLine } from "./match-line-format";
19
20
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
20
21
  import {
21
22
  combineSearchGlobs,
@@ -34,13 +35,13 @@ const grepSchema = Type.Object({
34
35
  path: Type.Optional(Type.String({ description: "File or directory to search (default: cwd)" })),
35
36
  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
36
37
  type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
37
- i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
38
+ i: Type.Optional(Type.Boolean({ description: "Case-insensitive search", default: false })),
38
39
  pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })),
39
40
  post: Type.Optional(Type.Number({ description: "Lines of context after matches" })),
40
41
  multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })),
41
- gitignore: Type.Optional(Type.Boolean({ description: "Respect .gitignore files during search (default: true)" })),
42
- limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 20)" })),
43
- offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
42
+ gitignore: Type.Optional(Type.Boolean({ description: "Respect .gitignore files during search", default: true })),
43
+ limit: Type.Optional(Type.Number({ description: "Limit output to first N matches", default: 20 })),
44
+ offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit", default: 0 })),
44
45
  });
45
46
 
46
47
  export type GrepToolInput = Static<typeof grepSchema>;
@@ -123,6 +124,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
123
124
  };
124
125
  let searchPath: string;
125
126
  let scopePath: string;
127
+ let exactFilePaths: string[] | undefined;
126
128
  let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined;
127
129
  const internalRouter = this.session.internalRouter;
128
130
  if (searchDir?.trim()) {
@@ -141,7 +143,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
141
143
  const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
142
144
  if (multiSearchPath) {
143
145
  searchPath = multiSearchPath.basePath;
144
- globFilter = multiSearchPath.glob;
146
+ globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
147
+ exactFilePaths = multiSearchPath.exactFilePaths;
145
148
  scopePath = multiSearchPath.scopePath;
146
149
  } else {
147
150
  const parsedPath = parseSearchPath(rawPath);
@@ -173,26 +176,61 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
173
176
  // Run grep
174
177
  let result: GrepResult;
175
178
  try {
176
- result = await grep(
177
- {
178
- pattern: normalizedPattern,
179
- path: searchPath,
180
- glob: globFilter,
181
- type: type?.trim() || undefined,
182
- ignoreCase,
183
- multiline: effectiveMultiline,
184
- hidden: true,
185
- gitignore: useGitignore,
186
- cache: false,
187
- maxCount: internalLimit,
188
- offset: normalizedOffset > 0 ? normalizedOffset : undefined,
189
- contextBefore: normalizedContextBefore,
190
- contextAfter: normalizedContextAfter,
191
- maxColumns: DEFAULT_MAX_COLUMN,
192
- mode: effectiveOutputMode,
193
- },
194
- undefined,
195
- );
179
+ if (exactFilePaths) {
180
+ const matches: GrepMatch[] = [];
181
+ let limitReached = false;
182
+ for (const exactFilePath of exactFilePaths) {
183
+ const fileResult = await grep(
184
+ {
185
+ pattern: normalizedPattern,
186
+ path: exactFilePath,
187
+ type: type?.trim() || undefined,
188
+ ignoreCase,
189
+ multiline: effectiveMultiline,
190
+ hidden: true,
191
+ gitignore: useGitignore,
192
+ cache: false,
193
+ contextBefore: normalizedContextBefore,
194
+ contextAfter: normalizedContextAfter,
195
+ maxColumns: DEFAULT_MAX_COLUMN,
196
+ mode: effectiveOutputMode,
197
+ },
198
+ undefined,
199
+ );
200
+ limitReached = limitReached || Boolean(fileResult.limitReached);
201
+ const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
202
+ matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
203
+ }
204
+ const offsetMatches = matches.slice(normalizedOffset);
205
+ result = {
206
+ matches: offsetMatches,
207
+ totalMatches: offsetMatches.length,
208
+ filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
209
+ filesSearched: exactFilePaths.length,
210
+ limitReached,
211
+ };
212
+ } else {
213
+ result = await grep(
214
+ {
215
+ pattern: normalizedPattern,
216
+ path: searchPath,
217
+ glob: globFilter,
218
+ type: type?.trim() || undefined,
219
+ ignoreCase,
220
+ multiline: effectiveMultiline,
221
+ hidden: true,
222
+ gitignore: useGitignore,
223
+ cache: false,
224
+ maxCount: internalLimit,
225
+ offset: normalizedOffset > 0 ? normalizedOffset : undefined,
226
+ contextBefore: normalizedContextBefore,
227
+ contextAfter: normalizedContextAfter,
228
+ maxColumns: DEFAULT_MAX_COLUMN,
229
+ mode: effectiveOutputMode,
230
+ },
231
+ undefined,
232
+ );
233
+ }
196
234
  } catch (err) {
197
235
  if (err instanceof Error && err.message.startsWith("regex parse error")) {
198
236
  throw new ToolError(err.message);
@@ -243,15 +281,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
243
281
  ? roundRobinSelect(result.matches, effectiveLimit)
244
282
  : result.matches.slice(0, effectiveLimit);
245
283
  const matchLimitReached = result.matches.length > effectiveLimit;
246
- const files = new Set<string>();
247
- const fileList: string[] = [];
284
+ const { record: recordFile, list: fileList } = createFileRecorder();
248
285
  const fileMatchCounts = new Map<string, number>();
249
- const recordFile = (relativePath: string) => {
250
- if (!files.has(relativePath)) {
251
- files.add(relativePath);
252
- fileList.push(relativePath);
253
- }
254
- };
255
286
  if (selectedMatches.length === 0) {
256
287
  const details: GrepToolDetails = {
257
288
  scopePath,
@@ -264,6 +295,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
264
295
  }
265
296
  const outputLines: string[] = [];
266
297
  let linesTruncated = false;
298
+ const hasContextLines = normalizedContextBefore > 0 || normalizedContextAfter > 0;
267
299
  const matchesByFile = new Map<string, GrepMatch[]>();
268
300
  for (const match of selectedMatches) {
269
301
  const relativePath = formatPath(match.path);
@@ -295,10 +327,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
295
327
  }
296
328
  chunkMatchesByFile.get(match.displayPath)!.push(match);
297
329
  }
298
- const renderChunkedMatchesForFile = (relativePath: string) => {
330
+ const renderChunkedMatchesForFile = (relativePath: string): string[] => {
331
+ const renderedLines: string[] = [];
299
332
  const fileMatches = chunkMatchesByFile.get(relativePath) ?? [];
300
333
  if (fileMatches.length === 0) {
301
- return;
334
+ return renderedLines;
302
335
  }
303
336
  const lineWidth = fileMatches[0]?.fileLineCount.toString().length ?? 1;
304
337
  const matchesByChunk = new Map<string, ChunkedGrepMatch[]>();
@@ -316,13 +349,14 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
316
349
  const anchor = chunkChecksum
317
350
  ? `${dashes}@${chunkPath}#${chunkChecksum}`
318
351
  : `${dashes}@${chunkPath}`;
319
- outputLines.push(anchor);
352
+ renderedLines.push(anchor);
320
353
  }
321
354
  for (const match of chunkMatches) {
322
- outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
355
+ renderedLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
323
356
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
324
357
  }
325
358
  }
359
+ return renderedLines;
326
360
  };
327
361
  if (isDirectory) {
328
362
  const filesByDirectory = new Map<string, string[]>();
@@ -336,26 +370,32 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
336
370
  for (const [directory, directoryFiles] of filesByDirectory) {
337
371
  if (directory === ".") {
338
372
  for (const relativePath of directoryFiles) {
373
+ const renderedLines = renderChunkedMatchesForFile(relativePath);
374
+ if (renderedLines.length === 0) continue;
339
375
  if (outputLines.length > 0) {
340
376
  outputLines.push("");
341
377
  }
342
378
  outputLines.push(`# ${path.basename(relativePath)}`);
343
- renderChunkedMatchesForFile(relativePath);
379
+ outputLines.push(...renderedLines);
344
380
  }
345
381
  continue;
346
382
  }
383
+ const renderedFiles = directoryFiles
384
+ .map(relativePath => ({ relativePath, lines: renderChunkedMatchesForFile(relativePath) }))
385
+ .filter(file => file.lines.length > 0);
386
+ if (renderedFiles.length === 0) continue;
347
387
  if (outputLines.length > 0) {
348
388
  outputLines.push("");
349
389
  }
350
390
  outputLines.push(`# ${directory}`);
351
- for (const relativePath of directoryFiles) {
391
+ for (const { relativePath, lines } of renderedFiles) {
352
392
  outputLines.push(`## └─ ${path.basename(relativePath)}`);
353
- renderChunkedMatchesForFile(relativePath);
393
+ outputLines.push(...lines);
354
394
  }
355
395
  }
356
396
  } else {
357
397
  for (const relativePath of fileList) {
358
- renderChunkedMatchesForFile(relativePath);
398
+ outputLines.push(...renderChunkedMatchesForFile(relativePath));
359
399
  }
360
400
  }
361
401
  const rawOutput = outputLines.join("\n");
@@ -386,7 +426,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
386
426
  }
387
427
  return resultBuilder.done();
388
428
  }
389
- const renderMatchesForFile = (relativePath: string) => {
429
+ const renderMatchesForFile = (relativePath: string): string[] => {
430
+ const renderedLines: string[] = [];
390
431
  const fileMatches = matchesByFile.get(relativePath) ?? [];
391
432
  for (const match of fileMatches) {
392
433
  const lineNumbers: number[] = [match.lineNumber];
@@ -401,31 +442,25 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
401
442
  }
402
443
  }
403
444
  const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));
404
- const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
405
- const separator = isMatch ? ":" : "-";
406
- if (useHashLines) {
407
- const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
408
- return `${ref}${separator}${line}`;
409
- }
410
- const padded = lineNumber.toString().padStart(lineWidth, " ");
411
- return `${padded}${separator}${line}`;
412
- };
445
+ const formatLine = (lineNumber: number, line: string, isMatch: boolean): string =>
446
+ formatMatchLine(lineNumber, line, isMatch, { useHashLines, lineWidth });
413
447
  if (match.contextBefore) {
414
448
  for (const ctx of match.contextBefore) {
415
- outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
449
+ renderedLines.push(formatLine(ctx.lineNumber, ctx.line, false));
416
450
  }
417
451
  }
418
- outputLines.push(formatLine(match.lineNumber, match.line, true));
452
+ renderedLines.push(formatLine(match.lineNumber, match.line, true));
419
453
  if (match.truncated) {
420
454
  linesTruncated = true;
421
455
  }
422
456
  if (match.contextAfter) {
423
457
  for (const ctx of match.contextAfter) {
424
- outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
458
+ renderedLines.push(formatLine(ctx.lineNumber, ctx.line, false));
425
459
  }
426
460
  }
427
461
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
428
462
  }
463
+ return renderedLines;
429
464
  };
430
465
  if (isDirectory) {
431
466
  const filesByDirectory = new Map<string, string[]>();
@@ -439,28 +474,37 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
439
474
  for (const [directory, directoryFiles] of filesByDirectory) {
440
475
  if (directory === ".") {
441
476
  for (const relativePath of directoryFiles) {
477
+ const renderedLines = renderMatchesForFile(relativePath);
478
+ if (renderedLines.length === 0) continue;
442
479
  if (outputLines.length > 0) {
443
480
  outputLines.push("");
444
481
  }
445
482
  outputLines.push(`# ${path.basename(relativePath)}`);
446
- renderMatchesForFile(relativePath);
483
+ outputLines.push(...renderedLines);
447
484
  }
448
485
  continue;
449
486
  }
487
+ const renderedFiles = directoryFiles
488
+ .map(relativePath => ({ relativePath, lines: renderMatchesForFile(relativePath) }))
489
+ .filter(file => file.lines.length > 0);
490
+ if (renderedFiles.length === 0) continue;
450
491
  if (outputLines.length > 0) {
451
492
  outputLines.push("");
452
493
  }
453
494
  outputLines.push(`# ${directory}`);
454
- for (const relativePath of directoryFiles) {
495
+ for (const { relativePath, lines } of renderedFiles) {
455
496
  outputLines.push(`## └─ ${path.basename(relativePath)}`);
456
- renderMatchesForFile(relativePath);
497
+ outputLines.push(...lines);
457
498
  }
458
499
  }
459
500
  } else {
460
501
  for (const relativePath of fileList) {
461
- renderMatchesForFile(relativePath);
502
+ outputLines.push(...renderMatchesForFile(relativePath));
462
503
  }
463
504
  }
505
+ if (hasContextLines && outputLines.length > 0) {
506
+ outputLines.unshift("[grep] match lines use ':'; context lines use '-'.");
507
+ }
464
508
  const rawOutput = outputLines.join("\n");
465
509
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
466
510
  const output = truncation.content;
@@ -197,3 +197,19 @@ function normalizeMixedSchemaNode(schema: unknown): unknown {
197
197
  export function jtdToJsonSchema(schema: unknown): unknown {
198
198
  return normalizeMixedSchemaNode(schema);
199
199
  }
200
+
201
+ /**
202
+ * Normalize a schema input that may be a JSON string, object, or null/undefined.
203
+ * Returns { normalized } on success, or { error } if JSON parsing fails.
204
+ */
205
+ export function normalizeSchema(schema: unknown): { normalized?: unknown; error?: string } {
206
+ if (schema === undefined || schema === null) return {};
207
+ if (typeof schema === "string") {
208
+ try {
209
+ return { normalized: JSON.parse(schema) };
210
+ } catch (err) {
211
+ return { error: err instanceof Error ? err.message : String(err) };
212
+ }
213
+ }
214
+ return { normalized: schema };
215
+ }
@@ -0,0 +1,20 @@
1
+ import { computeLineHash } from "../edit/line-hash";
2
+
3
+ /**
4
+ * Format a single line of match output for grep/ast-grep style results.
5
+ * Uses hashline refs when hashlines are enabled, otherwise pads the number.
6
+ */
7
+ export function formatMatchLine(
8
+ lineNumber: number,
9
+ line: string,
10
+ isMatch: boolean,
11
+ options: { useHashLines: boolean; lineWidth: number },
12
+ ): string {
13
+ const separator = isMatch ? ":" : "-";
14
+ if (options.useHashLines) {
15
+ const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
16
+ return `${ref}${separator}${line}`;
17
+ }
18
+ const padded = lineNumber.toString().padStart(options.lineWidth, " ");
19
+ return `${padded}${separator}${line}`;
20
+ }
@@ -74,7 +74,7 @@ function normalizeAtPrefix(filePath: string): string {
74
74
  withoutAt.startsWith("artifact://") ||
75
75
  withoutAt.startsWith("skill://") ||
76
76
  withoutAt.startsWith("rule://") ||
77
- withoutAt.startsWith("local://") ||
77
+ withoutAt.startsWith("local:") ||
78
78
  withoutAt.startsWith("mcp://")
79
79
  ) {
80
80
  return withoutAt;
@@ -110,6 +110,29 @@ export function expandPath(filePath: string): string {
110
110
  return expandTilde(normalized);
111
111
  }
112
112
 
113
+ function assertNotInternalUrl(expanded: string, original: string): void {
114
+ for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
115
+ if (expanded.startsWith(prefix)) {
116
+ throw new Error(
117
+ `Path "${original}" uses internal scheme "${prefix}" and must be resolved through the proper protocol handler, not as a filesystem path.`,
118
+ );
119
+ }
120
+ }
121
+ }
122
+
123
+ export function normalizeLocalScheme(filePath: string): string {
124
+ return filePath.replace(/^(local:)\/(?!\/)/, "$1//");
125
+ }
126
+
127
+ export function isInternalUrlPath(filePath: string): boolean {
128
+ const normalized = normalizeLocalScheme(filePath);
129
+ const expandedAndNormalized = normalizeLocalScheme(expandPath(normalized));
130
+ for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
131
+ if (expandedAndNormalized.startsWith(prefix)) return true;
132
+ }
133
+ return false;
134
+ }
135
+
113
136
  /**
114
137
  * Resolve a path relative to the given cwd.
115
138
  * Handles ~ expansion and absolute paths.
@@ -119,7 +142,12 @@ export function expandPath(filePath: string): string {
119
142
  * filesystem root is almost never what they intended.
120
143
  */
121
144
  export function resolveToCwd(filePath: string, cwd: string): string {
122
- const expanded = expandPath(filePath);
145
+ const normalized = normalizeLocalScheme(filePath);
146
+ const expanded = expandPath(normalized);
147
+ const expandedAndNormalized = normalizeLocalScheme(expanded);
148
+
149
+ assertNotInternalUrl(expandedAndNormalized, normalized);
150
+
123
151
  if (/^\/+$/.test(expanded)) {
124
152
  return cwd;
125
153
  }
@@ -165,6 +193,7 @@ export interface ResolvedMultiSearchPath {
165
193
  basePath: string;
166
194
  glob?: string;
167
195
  scopePath: string;
196
+ exactFilePaths?: string[];
168
197
  }
169
198
 
170
199
  export interface ResolvedMultiFindPattern {
@@ -410,6 +439,28 @@ async function areDelimitedTokensResolvable(
410
439
  return true;
411
440
  }
412
441
 
442
+ async function filterResolvableTokens(
443
+ tokens: string[],
444
+ cwd: string,
445
+ parseBasePath: (value: string) => string,
446
+ ): Promise<string[]> {
447
+ const out: string[] = [];
448
+ for (const token of tokens) {
449
+ if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) continue;
450
+ const basePath = parseBasePath(token);
451
+ const resolvedBasePath = resolveToCwd(basePath, cwd);
452
+ if (await pathExists(resolvedBasePath)) {
453
+ out.push(token);
454
+ continue;
455
+ }
456
+ const resolvedExactPath = resolveToCwd(token, cwd);
457
+ if (await pathExists(resolvedExactPath)) {
458
+ out.push(token);
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
413
464
  async function splitDelimitedSearchInput(
414
465
  rawInput: string,
415
466
  cwd: string,
@@ -424,8 +475,11 @@ async function splitDelimitedSearchInput(
424
475
  }
425
476
 
426
477
  const commaSeparated = splitTopLevel(trimmed, "comma");
427
- if (commaSeparated.length > 1 && (await areDelimitedTokensResolvable(commaSeparated, cwd, parseBasePath, true))) {
428
- return [...new Set(commaSeparated)];
478
+ if (commaSeparated.length > 1) {
479
+ const resolvable = await filterResolvableTokens(commaSeparated, cwd, parseBasePath);
480
+ if (resolvable.length >= 1) {
481
+ return [...new Set(resolvable)];
482
+ }
429
483
  }
430
484
 
431
485
  const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
@@ -445,7 +499,7 @@ export async function resolveMultiSearchPath(
445
499
  suffixGlob?: string,
446
500
  ): Promise<ResolvedMultiSearchPath | undefined> {
447
501
  const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
448
- if (!pathItems || pathItems.length <= 1) {
502
+ if (!pathItems || pathItems.length < 1) {
449
503
  return undefined;
450
504
  }
451
505
 
@@ -458,6 +512,7 @@ export async function resolveMultiSearchPath(
458
512
  }),
459
513
  );
460
514
 
515
+ const allExactFiles = !suffixGlob && parsedItems.every(item => !item.parsedPath.glob && item.stat.isFile());
461
516
  const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
462
517
  const combinedPatterns = parsedItems.map(item => {
463
518
  const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
@@ -479,6 +534,7 @@ export async function resolveMultiSearchPath(
479
534
  basePath: commonBasePath,
480
535
  glob: buildBraceUnion(combinedPatterns),
481
536
  scopePath: toScopeDisplay(pathItems),
537
+ exactFilePaths: allExactFiles ? parsedItems.map(item => item.absoluteBasePath) : undefined,
482
538
  };
483
539
  }
484
540
 
@@ -1,19 +1,20 @@
1
1
  import { resolveLocalUrlToPath } from "../internal-urls";
2
2
  import type { ToolSession } from ".";
3
- import { resolveToCwd } from "./path-utils";
3
+ import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
4
4
  import { ToolError } from "./tool-errors";
5
5
 
6
- const LOCAL_URL_PREFIX = "local://";
6
+ const LOCAL_SCHEME_PREFIX = "local:";
7
7
 
8
8
  export function resolvePlanPath(session: ToolSession, targetPath: string): string {
9
- if (targetPath.startsWith(LOCAL_URL_PREFIX)) {
10
- return resolveLocalUrlToPath(targetPath, {
9
+ const normalized = normalizeLocalScheme(targetPath);
10
+ if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
11
+ return resolveLocalUrlToPath(normalized, {
11
12
  getArtifactsDir: session.getArtifactsDir,
12
13
  getSessionId: session.getSessionId,
13
14
  });
14
15
  }
15
16
 
16
- return resolveToCwd(targetPath, session.cwd);
17
+ return resolveToCwd(normalized, session.cwd);
17
18
  }
18
19
 
19
20
  export function enforcePlanModeWrite(
@@ -52,7 +52,7 @@ export const pythonSchema = Type.Object({
52
52
  }),
53
53
  { description: "Cells to execute sequentially in persistent kernel" },
54
54
  ),
55
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 30)" })),
55
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 30 })),
56
56
  cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
57
57
  reset: Type.Optional(Type.Boolean({ description: "Restart kernel before execution" })),
58
58
  });
package/src/tools/read.ts CHANGED
@@ -371,7 +371,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
371
371
  const readSchema = Type.Object({
372
372
  path: Type.String({ description: "Path or URL to read" }),
373
373
  sel: Type.Optional(Type.String({ description: "Selector: chunk path, L10-L50, or raw" })),
374
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 20)" })),
374
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 20 })),
375
375
  });
376
376
 
377
377
  export type ReadToolInput = Static<typeof readSchema>;
@@ -4,8 +4,10 @@
4
4
  * Provides consistent formatting, truncation, and display patterns across all
5
5
  * tool renderers to ensure a unified TUI experience.
6
6
  */
7
+
7
8
  import * as os from "node:os";
8
9
  import * as path from "node:path";
10
+ import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
9
11
  import type { Ellipsis } from "@oh-my-pi/pi-natives";
10
12
  import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
11
13
  import { pluralize } from "@oh-my-pi/pi-utils";
@@ -210,6 +212,10 @@ interface ParsedDiagnostic {
210
212
  code?: string;
211
213
  }
212
214
 
215
+ function sanitizeDiagnosticDisplayText(text: string): string {
216
+ return replaceTabs(text);
217
+ }
218
+
213
219
  function getSeverityRank(severity: ParsedDiagnostic["severity"]): number {
214
220
  switch (severity) {
215
221
  case "error":
@@ -227,13 +233,13 @@ function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
227
233
  const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
228
234
  if (!match) return null;
229
235
  return {
230
- filePath: match[1],
236
+ filePath: sanitizeDiagnosticDisplayText(match[1]),
231
237
  line: parseInt(match[2], 10),
232
238
  col: parseInt(match[3], 10),
233
239
  severity: match[4] as ParsedDiagnostic["severity"],
234
- source: match[5],
235
- message: match[6],
236
- code: match[7],
240
+ source: match[5] ? sanitizeDiagnosticDisplayText(match[5]) : undefined,
241
+ message: sanitizeDiagnosticDisplayText(match[6]),
242
+ code: match[7] ? sanitizeDiagnosticDisplayText(match[7]) : undefined,
237
243
  };
238
244
  }
239
245
 
@@ -255,7 +261,7 @@ export function formatDiagnostics(
255
261
  existing.push(parsed);
256
262
  byFile.set(parsed.filePath, existing);
257
263
  } else {
258
- unparsed.push(msg);
264
+ unparsed.push(sanitizeDiagnosticDisplayText(msg));
259
265
  }
260
266
  }
261
267
 
@@ -272,7 +278,8 @@ export function formatDiagnostics(
272
278
  const headerIcon = diag.errored
273
279
  ? theme.styledSymbol("status.error", "error")
274
280
  : theme.styledSymbol("status.warning", "warning");
275
- let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
281
+ const summary = sanitizeDiagnosticDisplayText(diag.summary);
282
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${summary})`)}`;
276
283
 
277
284
  const maxDiags = expanded ? diag.messages.length : 5;
278
285
  let diagsShown = 0;
@@ -616,3 +623,28 @@ export function formatParseErrors(errors: string[]): string[] {
616
623
  : "Parse issues:";
617
624
  return [header, ...capped.map(err => `- ${err}`)];
618
625
  }
626
+
627
+ // =============================================================================
628
+ // LSP Batching
629
+ // =============================================================================
630
+
631
+ const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
632
+
633
+ export interface LspBatchRequest {
634
+ id: string;
635
+ flush: boolean;
636
+ }
637
+
638
+ export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBatchRequest | undefined {
639
+ if (!toolCall) {
640
+ return undefined;
641
+ }
642
+ const hasOtherWrites = toolCall.toolCalls.some(
643
+ (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
644
+ );
645
+ if (!hasOtherWrites) {
646
+ return undefined;
647
+ }
648
+ const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name));
649
+ return { id: toolCall.batchId, flush: !hasLaterWrites };
650
+ }
@@ -51,6 +51,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
51
51
  python: pythonToolRenderer as ToolRenderer,
52
52
  calc: calculatorToolRenderer as ToolRenderer,
53
53
  edit: editToolRenderer as ToolRenderer,
54
+ apply_patch: editToolRenderer as ToolRenderer,
54
55
  find: findToolRenderer as ToolRenderer,
55
56
  grep: grepToolRenderer as ToolRenderer,
56
57
  lsp: lspToolRenderer as ToolRenderer,
@@ -23,6 +23,7 @@ export interface ResolveToolDetails {
23
23
  reason: string;
24
24
  sourceToolName?: string;
25
25
  label?: string;
26
+ sourceResultDetails?: unknown;
26
27
  }
27
28
 
28
29
  function resolveReasonPreview(reason?: string): string | undefined {
@@ -67,14 +68,21 @@ export function queueResolveHandler(
67
68
  onRejected: () => "requeue",
68
69
  onInvoked: async (input: unknown) => {
69
70
  const params = input as ResolveParams;
71
+ const withResolveDetails = (result: AgentToolResult<unknown>): AgentToolResult<ResolveToolDetails> => ({
72
+ ...result,
73
+ details: {
74
+ ...detailsFor(params),
75
+ ...(result.details != null ? { sourceResultDetails: result.details } : {}),
76
+ },
77
+ });
70
78
  if (params.action === "apply") {
71
79
  const result = await options.apply(params.reason);
72
- return { ...result, details: detailsFor(params) };
80
+ return withResolveDetails(result);
73
81
  }
74
82
  if (params.action === "discard" && options.reject != null) {
75
83
  const result = await options.reject(params.reason);
76
84
  if (result != null) {
77
- return { ...result, details: detailsFor(params) };
85
+ return withResolveDetails(result);
78
86
  }
79
87
  }
80
88
  return {
@@ -154,9 +162,10 @@ export const resolveToolRenderer = {
154
162
  const reason = replaceTabs(details?.reason?.trim() || "No reason provided");
155
163
  const action = details?.action ?? "apply";
156
164
  const isApply = action === "apply" && !result.isError;
165
+ const isFailedApply = action === "apply" && result.isError;
157
166
  const bgColor = result.isError ? "error" : isApply ? "success" : "warning";
158
167
  const icon = isApply ? uiTheme.status.success : uiTheme.status.error;
159
- const verb = isApply ? "Accept" : "Discard";
168
+ const verb = isApply ? "Accept" : isFailedApply ? "Failed" : "Discard";
160
169
  const separator = ": ";
161
170
  const separatorIndex = label.indexOf(separator);
162
171
  const sourceLabel = separatorIndex > 0 ? label.slice(0, separatorIndex).trim() : undefined;