@oh-my-pi/pi-coding-agent 14.5.14 → 14.6.0

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 (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/config/settings-schema.ts +1 -1
  18. package/src/config/settings.ts +20 -1
  19. package/src/cursor.ts +1 -1
  20. package/src/edit/index.ts +9 -31
  21. package/src/edit/line-hash.ts +70 -43
  22. package/src/edit/modes/hashline.lark +26 -0
  23. package/src/edit/modes/hashline.ts +898 -1099
  24. package/src/edit/modes/patch.ts +0 -7
  25. package/src/edit/modes/replace.ts +0 -4
  26. package/src/edit/renderer.ts +22 -20
  27. package/src/edit/streaming.ts +8 -28
  28. package/src/eval/eval.lark +24 -30
  29. package/src/eval/js/context-manager.ts +5 -162
  30. package/src/eval/js/prelude.txt +0 -12
  31. package/src/eval/parse.ts +129 -129
  32. package/src/eval/py/prelude.py +1 -219
  33. package/src/export/html/template.generated.ts +1 -1
  34. package/src/export/html/template.js +2 -2
  35. package/src/internal-urls/docs-index.generated.ts +1 -1
  36. package/src/modes/components/session-observer-overlay.ts +5 -2
  37. package/src/modes/components/status-line/segments.ts +1 -1
  38. package/src/modes/components/status-line.ts +3 -5
  39. package/src/modes/components/tree-selector.ts +4 -5
  40. package/src/modes/components/welcome.ts +11 -1
  41. package/src/modes/controllers/command-controller.ts +2 -6
  42. package/src/modes/controllers/event-controller.ts +1 -2
  43. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  44. package/src/modes/controllers/input-controller.ts +0 -1
  45. package/src/modes/controllers/selector-controller.ts +1 -1
  46. package/src/modes/interactive-mode.ts +5 -7
  47. package/src/prompts/system/system-prompt.md +14 -38
  48. package/src/prompts/tools/ast-edit.md +8 -8
  49. package/src/prompts/tools/ast-grep.md +10 -10
  50. package/src/prompts/tools/eval.md +13 -31
  51. package/src/prompts/tools/find.md +2 -1
  52. package/src/prompts/tools/hashline.md +66 -57
  53. package/src/prompts/tools/search.md +2 -2
  54. package/src/session/session-manager.ts +17 -13
  55. package/src/tools/ast-edit.ts +141 -44
  56. package/src/tools/ast-grep.ts +112 -36
  57. package/src/tools/eval.ts +2 -53
  58. package/src/tools/find.ts +16 -15
  59. package/src/tools/path-utils.ts +36 -196
  60. package/src/tools/search.ts +56 -35
  61. package/src/utils/edit-mode.ts +2 -11
  62. package/src/utils/file-display-mode.ts +1 -1
  63. package/src/utils/git.ts +17 -0
  64. package/src/utils/session-color.ts +0 -12
  65. package/src/utils/title-generator.ts +22 -38
  66. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  67. package/src/autoresearch/contract.ts +0 -288
  68. package/src/edit/modes/atom.lark +0 -29
  69. package/src/edit/modes/atom.ts +0 -1773
  70. package/src/prompts/tools/atom.md +0 -150
package/src/tools/find.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  formatPathRelativeToCwd,
28
28
  normalizePathLikeInput,
29
29
  parseFindPattern,
30
- resolveMultiFindPattern,
30
+ resolveExplicitFindPatterns,
31
31
  resolveToCwd,
32
32
  } from "./path-utils";
33
33
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
@@ -35,9 +35,10 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
35
35
  import { toolResult } from "./tool-result";
36
36
 
37
37
  const findSchema = Type.Object({
38
- pattern: Type.String({
39
- description: "glob including search path",
40
- examples: ["src/**/*.ts", "lib/*.json", "apps/,packages/", "*.ts"],
38
+ paths: Type.Array(Type.String({ description: "glob including search path" }), {
39
+ minItems: 1,
40
+ description: "globs including search paths",
41
+ examples: [["src/**/*.ts"], ["lib/*.json"], ["apps/", "packages/"], ["*.ts"]],
41
42
  }),
42
43
  hidden: Type.Optional(Type.Boolean({ description: "include hidden files", default: true })),
43
44
  limit: Type.Optional(Type.Number({ description: "max results", default: 1000 })),
@@ -104,17 +105,17 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
104
105
  onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
105
106
  _context?: AgentToolContext,
106
107
  ): Promise<AgentToolResult<FindToolDetails>> {
107
- const { pattern, limit, hidden } = params;
108
+ const { paths, limit, hidden } = params;
108
109
 
109
110
  return untilAborted(signal, async () => {
110
111
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
111
- const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
112
- if (!normalizedPattern) {
113
- throw new ToolError("Pattern must not be empty");
112
+ const normalizedPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
113
+ if (normalizedPatterns.some(pattern => pattern.length === 0)) {
114
+ throw new ToolError("`paths` must contain non-empty globs or paths");
114
115
  }
115
116
 
116
- const multiPattern = await resolveMultiFindPattern(normalizedPattern, this.session.cwd);
117
- const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPattern);
117
+ const multiPattern = await resolveExplicitFindPatterns(normalizedPatterns, this.session.cwd);
118
+ const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPatterns[0] ?? ".");
118
119
  const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
119
120
  const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
120
121
  const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
@@ -292,7 +293,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
292
293
  // =============================================================================
293
294
 
294
295
  interface FindRenderArgs {
295
- pattern: string;
296
+ paths?: string[];
296
297
  limit?: number;
297
298
  }
298
299
 
@@ -305,7 +306,7 @@ export const findToolRenderer = {
305
306
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
306
307
 
307
308
  const text = renderStatusLine(
308
- { icon: "pending", title: "Find", description: args.pattern || "*", meta },
309
+ { icon: "pending", title: "Find", description: args.paths?.join(", ") || "*", meta },
309
310
  uiTheme,
310
311
  );
311
312
  return new Text(text, 0, 0);
@@ -342,7 +343,7 @@ export const findToolRenderer = {
342
343
  {
343
344
  icon: "success",
344
345
  title: "Find",
345
- description: args?.pattern,
346
+ description: args?.paths?.join(", "),
346
347
  meta: [formatCount("file", lines.length)],
347
348
  },
348
349
  uiTheme,
@@ -381,7 +382,7 @@ export const findToolRenderer = {
381
382
 
382
383
  if (fileCount === 0) {
383
384
  const header = renderStatusLine(
384
- { icon: "warning", title: "Find", description: args?.pattern, meta: ["0 files"] },
385
+ { icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
385
386
  uiTheme,
386
387
  );
387
388
  return new Text([header, formatEmptyMessage("No files found", uiTheme)].join("\n"), 0, 0);
@@ -390,7 +391,7 @@ export const findToolRenderer = {
390
391
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
391
392
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
392
393
  const header = renderStatusLine(
393
- { icon: truncated ? "warning" : "success", title: "Find", description: args?.pattern, meta },
394
+ { icon: truncated ? "warning" : "success", title: "Find", description: args?.paths?.join(", "), meta },
394
395
  uiTheme,
395
396
  );
396
397
 
@@ -47,15 +47,6 @@ function fileExists(filePath: string): boolean {
47
47
  }
48
48
  }
49
49
 
50
- async function pathExists(filePath: string): Promise<boolean> {
51
- try {
52
- await fs.promises.access(filePath, fs.constants.F_OK);
53
- return true;
54
- } catch {
55
- return false;
56
- }
57
- }
58
-
59
50
  function normalizeAtPrefix(filePath: string): string {
60
51
  if (!filePath.startsWith("@")) return filePath;
61
52
 
@@ -210,11 +201,17 @@ export interface ParsedFindPattern {
210
201
  hasGlob: boolean;
211
202
  }
212
203
 
204
+ export interface ResolvedSearchTarget {
205
+ basePath: string;
206
+ glob?: string;
207
+ }
208
+
213
209
  export interface ResolvedMultiSearchPath {
214
210
  basePath: string;
215
211
  glob?: string;
216
212
  scopePath: string;
217
213
  exactFilePaths?: string[];
214
+ targets?: ResolvedSearchTarget[];
218
215
  }
219
216
 
220
217
  export interface ResolvedMultiFindPattern {
@@ -294,77 +291,6 @@ export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): st
294
291
  return `${normalizedPrefix}/${normalizedSuffix}`;
295
292
  }
296
293
 
297
- type TopLevelSeparator = "comma" | "whitespace";
298
-
299
- function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
300
- const parts: string[] = [];
301
- let current = "";
302
- let braceDepth = 0;
303
- let bracketDepth = 0;
304
- let parenDepth = 0;
305
- let quote: '"' | "'" | undefined;
306
- let escaped = false;
307
-
308
- const pushCurrent = () => {
309
- const normalized = current.trim();
310
- if (normalized.length > 0) {
311
- parts.push(normalized);
312
- }
313
- current = "";
314
- };
315
-
316
- for (const char of value) {
317
- if (escaped) {
318
- current += char;
319
- escaped = false;
320
- continue;
321
- }
322
-
323
- if (char === "\\") {
324
- current += char;
325
- escaped = true;
326
- continue;
327
- }
328
-
329
- if (quote) {
330
- current += char;
331
- if (char === quote) {
332
- quote = undefined;
333
- }
334
- continue;
335
- }
336
-
337
- if (char === '"' || char === "'") {
338
- quote = char;
339
- current += char;
340
- continue;
341
- }
342
-
343
- if (char === "{") braceDepth += 1;
344
- else if (char === "}" && braceDepth > 0) braceDepth -= 1;
345
- else if (char === "[") bracketDepth += 1;
346
- else if (char === "]" && bracketDepth > 0) bracketDepth -= 1;
347
- else if (char === "(") parenDepth += 1;
348
- else if (char === ")" && parenDepth > 0) parenDepth -= 1;
349
-
350
- const topLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0;
351
- const isWhitespace = /\s/.test(char);
352
- if (topLevel && separator === "comma" && char === ",") {
353
- pushCurrent();
354
- continue;
355
- }
356
- if (topLevel && separator === "whitespace" && isWhitespace) {
357
- pushCurrent();
358
- continue;
359
- }
360
-
361
- current += char;
362
- }
363
-
364
- pushCurrent();
365
- return parts.length > 1 ? parts : [value.trim()];
366
- }
367
-
368
294
  function normalizePosixPath(filePath: string): string {
369
295
  return filePath.replace(/\\/g, "/");
370
296
  }
@@ -412,121 +338,12 @@ function toScopeDisplay(items: string[], cwd: string): string {
412
338
  .join(", ");
413
339
  }
414
340
 
415
- function looksLikeDelimitedPathToken(token: string): boolean {
416
- return (
417
- TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix)) ||
418
- token.startsWith(".") ||
419
- token.startsWith("/") ||
420
- token.startsWith("~") ||
421
- token.startsWith("@") ||
422
- token.includes("/") ||
423
- token.includes("\\") ||
424
- hasGlobPathChars(token) ||
425
- /\.[^./\\]+$/.test(token)
426
- );
427
- }
428
-
429
- async function areDelimitedTokensResolvable(
430
- tokens: string[],
431
- cwd: string,
432
- parseBasePath: (value: string) => string,
433
- allowBareExistingTokens: boolean,
434
- ): Promise<boolean> {
435
- for (const token of tokens) {
436
- if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) {
437
- return false;
438
- }
439
-
440
- if (!allowBareExistingTokens && !looksLikeDelimitedPathToken(token)) {
441
- // Bare names like "packages" don't look like path tokens syntactically,
442
- // but may still be valid directory names. Check existence before rejecting.
443
- const resolvedExactPath = resolveToCwd(token, cwd);
444
- if (!(await pathExists(resolvedExactPath))) {
445
- return false;
446
- }
447
- continue;
448
- }
449
-
450
- const basePath = parseBasePath(token);
451
- const resolvedBasePath = resolveToCwd(basePath, cwd);
452
- if (await pathExists(resolvedBasePath)) {
453
- continue;
454
- }
455
-
456
- if (!allowBareExistingTokens) {
457
- return false;
458
- }
459
-
460
- const resolvedExactPath = resolveToCwd(token, cwd);
461
- if (!(await pathExists(resolvedExactPath))) {
462
- return false;
463
- }
464
- }
465
-
466
- return true;
467
- }
468
-
469
- async function filterResolvableTokens(
470
- tokens: string[],
471
- cwd: string,
472
- parseBasePath: (value: string) => string,
473
- ): Promise<string[]> {
474
- const out: string[] = [];
475
- for (const token of tokens) {
476
- if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) continue;
477
- const basePath = parseBasePath(token);
478
- const resolvedBasePath = resolveToCwd(basePath, cwd);
479
- if (await pathExists(resolvedBasePath)) {
480
- out.push(token);
481
- continue;
482
- }
483
- const resolvedExactPath = resolveToCwd(token, cwd);
484
- if (await pathExists(resolvedExactPath)) {
485
- out.push(token);
486
- }
487
- }
488
- return out;
489
- }
490
-
491
- async function splitDelimitedSearchInput(
492
- rawInput: string,
493
- cwd: string,
494
- parseBasePath: (value: string) => string,
495
- ): Promise<string[] | undefined> {
496
- const trimmed = rawInput.trim();
497
- if (!trimmed) return undefined;
498
-
499
- const resolvedExactPath = resolveToCwd(trimmed, cwd);
500
- if (await pathExists(resolvedExactPath)) {
501
- return undefined;
502
- }
503
-
504
- const commaSeparated = splitTopLevel(trimmed, "comma");
505
- if (commaSeparated.length > 1) {
506
- const resolvable = await filterResolvableTokens(commaSeparated, cwd, parseBasePath);
507
- if (resolvable.length >= 1) {
508
- return [...new Set(resolvable)];
509
- }
510
- }
511
-
512
- const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
513
- if (
514
- whitespaceSeparated.length > 1 &&
515
- (await areDelimitedTokensResolvable(whitespaceSeparated, cwd, parseBasePath, false))
516
- ) {
517
- return [...new Set(whitespaceSeparated)];
518
- }
519
-
520
- return undefined;
521
- }
522
-
523
- export async function resolveMultiSearchPath(
524
- rawPath: string,
341
+ async function resolveSearchPathItems(
342
+ pathItems: string[],
525
343
  cwd: string,
526
344
  suffixGlob?: string,
527
345
  ): Promise<ResolvedMultiSearchPath | undefined> {
528
- const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
529
- if (!pathItems || pathItems.length < 1) {
346
+ if (pathItems.length < 1) {
530
347
  return undefined;
531
348
  }
532
349
 
@@ -556,21 +373,37 @@ export async function resolveMultiSearchPath(
556
373
  }
557
374
  return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
558
375
  });
376
+ const rootPath = path.parse(commonBasePath).root;
377
+ const isDegenerateRoot = commonBasePath === rootPath && parsedItems.length > 1;
378
+ const targets = isDegenerateRoot
379
+ ? parsedItems.map(item => ({
380
+ basePath: item.absoluteBasePath,
381
+ glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob,
382
+ }))
383
+ : undefined;
559
384
 
560
385
  return {
561
386
  basePath: commonBasePath,
562
387
  glob: buildBraceUnion(combinedPatterns),
563
388
  scopePath: toScopeDisplay(pathItems, cwd),
564
389
  exactFilePaths: allExactFiles ? parsedItems.map(item => item.absoluteBasePath) : undefined,
390
+ targets,
565
391
  };
566
392
  }
567
393
 
568
- export async function resolveMultiFindPattern(
569
- rawPattern: string,
394
+ export async function resolveExplicitSearchPaths(
395
+ pathItems: string[],
396
+ cwd: string,
397
+ suffixGlob?: string,
398
+ ): Promise<ResolvedMultiSearchPath | undefined> {
399
+ return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
400
+ }
401
+
402
+ async function resolveFindPatternItems(
403
+ patternItems: string[],
570
404
  cwd: string,
571
405
  ): Promise<ResolvedMultiFindPattern | undefined> {
572
- const patternItems = await splitDelimitedSearchInput(rawPattern, cwd, value => parseFindPattern(value).basePath);
573
- if (!patternItems || patternItems.length <= 1) {
406
+ if (patternItems.length <= 1) {
574
407
  return undefined;
575
408
  }
576
409
 
@@ -602,6 +435,13 @@ export async function resolveMultiFindPattern(
602
435
  };
603
436
  }
604
437
 
438
+ export async function resolveExplicitFindPatterns(
439
+ patternItems: string[],
440
+ cwd: string,
441
+ ): Promise<ResolvedMultiFindPattern | undefined> {
442
+ return resolveFindPatternItems([...new Set(patternItems)], cwd);
443
+ }
444
+
605
445
  export function resolveReadPath(filePath: string, cwd: string): string {
606
446
  const resolved = resolveToCwd(filePath, cwd);
607
447
  const shellEscapedVariant = tryShellEscapedPath(resolved);
@@ -22,7 +22,7 @@ import {
22
22
  hasGlobPathChars,
23
23
  normalizePathLikeInput,
24
24
  parseSearchPath,
25
- resolveMultiSearchPath,
25
+ resolveExplicitSearchPaths,
26
26
  resolveToCwd,
27
27
  } from "./path-utils";
28
28
  import {
@@ -37,9 +37,10 @@ import { toolResult } from "./tool-result";
37
37
 
38
38
  const searchSchema = Type.Object({
39
39
  pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
40
- path: Type.String({
41
- description: "file, directory, glob, comma-separated paths, or internal URL to search",
42
- examples: ["src/", "src/foo.ts", "src/**/*.ts"],
40
+ paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
41
+ minItems: 1,
42
+ description: "files, directories, globs, or internal URLs to search",
43
+ examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
43
44
  }),
44
45
  i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
45
46
  gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
@@ -48,7 +49,7 @@ const searchSchema = Type.Object({
48
49
 
49
50
  export type SearchToolInput = Static<typeof searchSchema>;
50
51
 
51
- const DEFAULT_MATCH_LIMIT = 20;
52
+ const DEFAULT_MATCH_LIMIT = 500;
52
53
 
53
54
  export interface SearchToolDetails {
54
55
  truncation?: TruncationResult;
@@ -93,7 +94,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
93
94
  _onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
94
95
  _toolContext?: AgentToolContext,
95
96
  ): Promise<AgentToolResult<SearchToolDetails>> {
96
- const { pattern, path: searchDir, i, gitignore, skip } = params;
97
+ const { pattern, paths, i, gitignore, skip } = params;
97
98
 
98
99
  return untilAborted(signal, async () => {
99
100
  const normalizedPattern = pattern.trim();
@@ -117,13 +118,19 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
117
118
  let searchPath: string;
118
119
  let scopePath: string;
119
120
  let exactFilePaths: string[] | undefined;
121
+ let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
120
122
  let globFilter: string | undefined;
121
- const rawPath = normalizePathLikeInput(searchDir);
122
- if (rawPath.length === 0) {
123
- throw new ToolError("`path` must be a non-empty path or glob");
123
+ const rawPaths = paths.map(normalizePathLikeInput);
124
+ if (rawPaths.some(rawPath => rawPath.length === 0)) {
125
+ throw new ToolError("`paths` must contain non-empty paths or globs");
124
126
  }
125
127
  const internalRouter = this.session.internalRouter;
126
- if (internalRouter?.canHandle(rawPath)) {
128
+ const resolvedPathInputs: string[] = [];
129
+ for (const rawPath of rawPaths) {
130
+ if (!internalRouter?.canHandle(rawPath)) {
131
+ resolvedPathInputs.push(rawPath);
132
+ continue;
133
+ }
127
134
  if (hasGlobPathChars(rawPath)) {
128
135
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
129
136
  }
@@ -131,28 +138,30 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
131
138
  if (!resource.sourcePath) {
132
139
  throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
133
140
  }
134
- searchPath = resource.sourcePath;
141
+ resolvedPathInputs.push(resource.sourcePath);
142
+ }
143
+ if (resolvedPathInputs.length === 1) {
144
+ const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
145
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
146
+ globFilter = parsedPath.glob;
135
147
  scopePath = formatScopePath(searchPath);
136
148
  } else {
137
- const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
138
- if (multiSearchPath) {
139
- searchPath = multiSearchPath.basePath;
140
- globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
141
- exactFilePaths = multiSearchPath.exactFilePaths;
142
- scopePath = multiSearchPath.scopePath;
143
- } else {
144
- const parsedPath = parseSearchPath(rawPath);
145
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
146
- globFilter = parsedPath.glob;
147
- scopePath = formatScopePath(searchPath);
149
+ const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
150
+ if (!multiSearchPath) {
151
+ throw new ToolError("`paths` must contain at least one path or glob");
148
152
  }
153
+ searchPath = multiSearchPath.basePath;
154
+ exactFilePaths = multiSearchPath.exactFilePaths;
155
+ multiTargets = multiSearchPath.targets;
156
+ globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
157
+ scopePath = multiSearchPath.scopePath;
149
158
  }
150
159
  let isDirectory: boolean;
151
160
  try {
152
161
  const stat = await Bun.file(searchPath).stat();
153
162
  isDirectory = stat.isDirectory();
154
163
  } catch {
155
- const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
164
+ const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
156
165
  throw new ToolError(`Path not found: ${scopePath}${hint}`);
157
166
  }
158
167
 
@@ -163,19 +172,26 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
163
172
  // Run grep
164
173
  let result: GrepResult;
165
174
  try {
166
- if (exactFilePaths) {
175
+ if (exactFilePaths || multiTargets) {
167
176
  const matches: GrepMatch[] = [];
168
177
  let limitReached = false;
169
- for (const exactFilePath of exactFilePaths) {
170
- const fileResult = await grep(
178
+ let totalMatches = 0;
179
+ let filesSearched = 0;
180
+ const targets = exactFilePaths
181
+ ? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined }))
182
+ : (multiTargets ?? []);
183
+ for (const target of targets) {
184
+ const targetResult = await grep(
171
185
  {
172
186
  pattern: normalizedPattern,
173
- path: exactFilePath,
187
+ path: target.basePath,
188
+ glob: target.glob,
174
189
  ignoreCase,
175
190
  multiline: effectiveMultiline,
176
191
  hidden: true,
177
192
  gitignore: useGitignore,
178
193
  cache: false,
194
+ maxCount: exactFilePaths ? undefined : internalLimit,
179
195
  contextBefore: normalizedContextBefore,
180
196
  contextAfter: normalizedContextAfter,
181
197
  maxColumns: DEFAULT_MAX_COLUMN,
@@ -183,16 +199,21 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
183
199
  },
184
200
  undefined,
185
201
  );
186
- limitReached = limitReached || Boolean(fileResult.limitReached);
187
- const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
188
- matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
202
+ limitReached = limitReached || Boolean(targetResult.limitReached);
203
+ totalMatches += targetResult.totalMatches;
204
+ filesSearched += targetResult.filesSearched;
205
+ for (const match of targetResult.matches) {
206
+ const absolute = path.resolve(target.basePath, match.path);
207
+ const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
208
+ matches.push({ ...match, path: rebased });
209
+ }
189
210
  }
190
211
  const offsetMatches = matches.slice(normalizedSkip);
191
212
  result = {
192
213
  matches: offsetMatches,
193
- totalMatches: offsetMatches.length,
214
+ totalMatches: exactFilePaths ? offsetMatches.length : totalMatches,
194
215
  filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
195
- filesSearched: exactFilePaths.length,
216
+ filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
196
217
  limitReached,
197
218
  };
198
219
  } else {
@@ -261,7 +282,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
261
282
  : result.matches.slice(0, effectiveLimit);
262
283
  const matchLimitReached = result.matches.length > effectiveLimit;
263
284
  const nextSkip = normalizedSkip + selectedMatches.length;
264
- const limitMessage = `Result limit reached; narrow path or use skip=${nextSkip}.`;
285
+ const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
265
286
  const { record: recordFile, list: fileList } = createFileRecorder();
266
287
  const fileMatchCounts = new Map<string, number>();
267
288
  if (selectedMatches.length === 0) {
@@ -381,7 +402,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
381
402
 
382
403
  interface SearchRenderArgs {
383
404
  pattern: string;
384
- path?: string;
405
+ paths?: string[];
385
406
  i?: boolean;
386
407
  gitignore?: boolean;
387
408
  skip?: number;
@@ -393,7 +414,7 @@ export const searchToolRenderer = {
393
414
  inline: true,
394
415
  renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
395
416
  const meta: string[] = [];
396
- if (args.path) meta.push(`in ${args.path}`);
417
+ if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
397
418
  if (args.i) meta.push("case:insensitive");
398
419
  if (args.gitignore === false) meta.push("gitignore:false");
399
420
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
@@ -1,12 +1,11 @@
1
- import { $env, $flag } from "@oh-my-pi/pi-utils";
1
+ import { $env } from "@oh-my-pi/pi-utils";
2
2
 
3
- export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch" | "atom";
3
+ export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch";
4
4
 
5
5
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
6
 
7
7
  const EDIT_MODE_IDS = {
8
8
  apply_patch: "apply_patch",
9
- atom: "atom",
10
9
  hashline: "hashline",
11
10
  patch: "patch",
12
11
  replace: "replace",
@@ -38,14 +37,6 @@ export function resolveEditMode(session: EditModeSessionLike): EditMode {
38
37
  const envMode = normalizeEditMode($env.PI_EDIT_VARIANT);
39
38
  if (envMode) return envMode;
40
39
 
41
- if (!$flag("PI_STRICT_EDIT_MODE")) {
42
- if (activeModel?.includes("spark")) return "apply_patch";
43
- if (activeModel?.includes("nano")) return "replace";
44
- if (activeModel?.includes("mini")) return "replace";
45
- if (activeModel?.includes("haiku")) return "replace";
46
- if (activeModel?.includes("flash")) return "replace";
47
- }
48
-
49
40
  const settingsMode = normalizeEditMode(String(session.settings.get("edit.mode") ?? ""));
50
41
  return settingsMode ?? DEFAULT_EDIT_MODE;
51
42
  }
@@ -29,7 +29,7 @@ export function resolveFileDisplayMode(session: FileDisplayModeSession, options?
29
29
  const { settings } = session;
30
30
  const hasEditTool = session.hasEditTool ?? true;
31
31
  const editMode = resolveEditMode(session);
32
- const usesHashLineAnchors = editMode === "hashline" || editMode === "atom";
32
+ const usesHashLineAnchors = editMode === "hashline";
33
33
  const raw = options?.raw === true;
34
34
  const hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
35
35
  return {
package/src/utils/git.ts CHANGED
@@ -1258,6 +1258,23 @@ export async function restore(cwd: string, options: RestoreOptions = {}): Promis
1258
1258
  await runEffect(cwd, args, { signal: options.signal });
1259
1259
  }
1260
1260
 
1261
+ /**
1262
+ * Run `git reset` with options. Default is a soft reset (no flag); pass `hard: true` for a destructive reset.
1263
+ *
1264
+ * NOTE: stage.reset() handles the per-file unstaging case. This helper exists for tree-wide resets.
1265
+ */
1266
+ export async function reset(
1267
+ cwd: string,
1268
+ options: { hard?: boolean; mixed?: boolean; soft?: boolean; target?: string; signal?: AbortSignal } = {},
1269
+ ): Promise<void> {
1270
+ const args = ["reset"];
1271
+ if (options.hard) args.push("--hard");
1272
+ else if (options.mixed) args.push("--mixed");
1273
+ else if (options.soft) args.push("--soft");
1274
+ if (options.target) args.push(options.target);
1275
+ await runEffect(cwd, args, { signal: options.signal });
1276
+ }
1277
+
1261
1278
  export async function clean(
1262
1279
  cwd: string,
1263
1280
  options: { ignoredOnly?: boolean; paths?: readonly string[]; signal?: AbortSignal } = {},
@@ -33,18 +33,6 @@ export function getSessionAccentHex(name: string): string {
33
33
  return hslToHex(nameToHue(name), 0.9, 0.72);
34
34
  }
35
35
 
36
- /**
37
- * Auto-generated titles should not drive the session accent.
38
- * Legacy sessions with unknown title source keep the old behavior.
39
- */
40
- export function getSessionAccentHexForTitle(
41
- name: string | undefined,
42
- titleSource: "auto" | "user" | undefined,
43
- ): string | undefined {
44
- if (!name || titleSource === "auto") return undefined;
45
- return getSessionAccentHex(name);
46
- }
47
-
48
36
  /**
49
37
  * Convert a hex accent color to an ANSI-16m foreground escape sequence.
50
38
  * Returns `undefined` if `hex` is nullish or Bun.color conversion fails.