@oh-my-pi/pi-coding-agent 13.15.3 → 13.16.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 (50) hide show
  1. package/CHANGELOG.md +30 -16
  2. package/package.json +7 -7
  3. package/src/commit/agentic/tools/analyze-file.ts +1 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/custom-tools/types.ts +3 -0
  7. package/src/extensibility/extensions/runner.ts +7 -0
  8. package/src/extensibility/extensions/types.ts +10 -1
  9. package/src/extensibility/hooks/types.ts +1 -1
  10. package/src/internal-urls/docs-index.generated.ts +1 -1
  11. package/src/ipy/cancellation.ts +28 -0
  12. package/src/ipy/executor.ts +252 -77
  13. package/src/ipy/kernel.ts +181 -35
  14. package/src/ipy/modules.ts +39 -4
  15. package/src/modes/acp/acp-agent.ts +1 -0
  16. package/src/modes/components/hook-editor.ts +57 -8
  17. package/src/modes/components/model-selector.ts +48 -29
  18. package/src/modes/components/settings-defs.ts +10 -1
  19. package/src/modes/components/settings-selector.ts +92 -5
  20. package/src/modes/controllers/extension-ui-controller.ts +35 -4
  21. package/src/modes/controllers/input-controller.ts +4 -3
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +7 -2
  24. package/src/modes/print-mode.ts +1 -0
  25. package/src/modes/prompt-action-autocomplete.ts +5 -3
  26. package/src/modes/rpc/rpc-mode.ts +79 -30
  27. package/src/modes/rpc/rpc-types.ts +9 -1
  28. package/src/modes/theme/theme.ts +70 -0
  29. package/src/modes/types.ts +6 -1
  30. package/src/prompts/system/custom-system-prompt.md +5 -0
  31. package/src/prompts/system/system-prompt.md +6 -0
  32. package/src/prompts/tools/ask.md +1 -0
  33. package/src/prompts/tools/grep.md +1 -1
  34. package/src/prompts/tools/hashline.md +20 -5
  35. package/src/sdk.ts +26 -2
  36. package/src/session/agent-session.ts +18 -11
  37. package/src/system-prompt.ts +63 -2
  38. package/src/task/executor.ts +4 -0
  39. package/src/task/index.ts +2 -0
  40. package/src/tools/ask.ts +109 -61
  41. package/src/tools/ast-edit.ts +2 -16
  42. package/src/tools/ast-grep.ts +2 -17
  43. package/src/tools/browser.ts +35 -17
  44. package/src/tools/find.ts +1 -0
  45. package/src/tools/grep.ts +25 -34
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/path-utils.ts +7 -0
  48. package/src/tools/python.ts +3 -2
  49. package/src/tools/render-utils.ts +27 -0
  50. package/src/tui/tree-list.ts +51 -22
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
+ import type { SearchDb } from "@oh-my-pi/pi-natives";
8
9
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
9
10
  import type { TSchema } from "@sinclair/typebox";
10
11
  import Ajv, { type ValidateFunction } from "ajv";
@@ -147,6 +148,7 @@ export interface ExecutorOptions {
147
148
  mcpManager?: MCPManager;
148
149
  authStorage?: AuthStorage;
149
150
  modelRegistry?: ModelRegistry;
151
+ searchDb?: SearchDb;
150
152
  settings?: Settings;
151
153
  }
152
154
 
@@ -950,6 +952,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
950
952
  cwd: worktree ?? cwd,
951
953
  authStorage,
952
954
  modelRegistry,
955
+ searchDb: options.searchDb,
953
956
  settings: subagentSettings,
954
957
  model,
955
958
  thinkingLevel: effectiveThinkingLevel,
@@ -1042,6 +1045,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1042
1045
  },
1043
1046
  {
1044
1047
  getModel: () => session.model,
1048
+ getSearchDb: () => session.searchDb,
1045
1049
  isIdle: () => !session.isStreaming,
1046
1050
  abort: () => session.abort(),
1047
1051
  hasPendingMessages: () => session.queuedMessageCount > 0,
package/src/task/index.ts CHANGED
@@ -775,6 +775,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
775
775
  },
776
776
  authStorage: this.session.authStorage,
777
777
  modelRegistry: this.session.modelRegistry,
778
+ searchDb: this.session.searchDb,
778
779
  settings: this.session.settings,
779
780
  mcpManager: this.session.mcpManager,
780
781
  contextFiles,
@@ -828,6 +829,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
828
829
  },
829
830
  authStorage: this.session.authStorage,
830
831
  modelRegistry: this.session.modelRegistry,
832
+ searchDb: this.session.searchDb,
831
833
  settings: this.session.settings,
832
834
  mcpManager: this.session.mcpManager,
833
835
  contextFiles,
package/src/tools/ask.ts CHANGED
@@ -147,9 +147,11 @@ interface UIContext {
147
147
  helpText?: string;
148
148
  },
149
149
  ): Promise<string | undefined>;
150
- input(
151
- prompt: string,
152
- options_?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void },
150
+ editor(
151
+ title: string,
152
+ prefill?: string,
153
+ dialogOptions?: { signal?: AbortSignal },
154
+ editorOptions?: { promptStyle?: boolean },
153
155
  ): Promise<string | undefined>;
154
156
  }
155
157
 
@@ -207,15 +209,11 @@ async function askSingleQuestion(
207
209
  return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
208
210
  };
209
211
 
210
- const promptForInput = async (): Promise<{ input: string | undefined; timedOut: boolean }> => {
211
- let inputTimedOut = false;
212
- const onTimeout = () => {
213
- inputTimedOut = true;
214
- };
215
- const input = signal
216
- ? await untilAborted(signal, () => ui.input("Enter your response:", { signal, timeout, onTimeout }))
217
- : await ui.input("Enter your response:", { signal, timeout, onTimeout });
218
- return { input, timedOut: inputTimedOut };
212
+ const promptForCustomInput = async (): Promise<{ input: string | undefined }> => {
213
+ const dialogOptions = signal ? { signal } : undefined;
214
+ const showCustomInput = () => ui.editor("Enter your response:", undefined, dialogOptions, { promptStyle: true });
215
+ const input = signal ? await untilAborted(signal, showCustomInput) : await showCustomInput();
216
+ return { input };
219
217
  };
220
218
 
221
219
  const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
@@ -264,9 +262,11 @@ async function askSingleQuestion(
264
262
  timedOut = true;
265
263
  break;
266
264
  }
267
- const inputResult = await promptForInput();
268
- if (inputResult.input) customInput = inputResult.input;
269
- if (inputResult.timedOut) timedOut = true;
265
+ const customResult = await promptForCustomInput();
266
+ if (customResult.input === undefined) {
267
+ break;
268
+ }
269
+ customInput = customResult.input;
270
270
  break;
271
271
  }
272
272
 
@@ -306,7 +306,7 @@ async function askSingleQuestion(
306
306
  if (previouslySelected) {
307
307
  const selectedIndex = optionLabels.indexOf(previouslySelected);
308
308
  if (selectedIndex >= 0) initialIndex = selectedIndex;
309
- } else if (customInput) {
309
+ } else if (customInput !== undefined) {
310
310
  initialIndex = displayLabels.length;
311
311
  }
312
312
  if (initialIndex !== undefined) {
@@ -330,11 +330,13 @@ async function askSingleQuestion(
330
330
  }
331
331
  } else if (choice === OTHER_OPTION) {
332
332
  if (!selectTimedOut) {
333
- const inputResult = await promptForInput();
334
- if (inputResult.input) customInput = inputResult.input;
335
- if (inputResult.timedOut) timedOut = true;
333
+ const customResult = await promptForCustomInput();
334
+ if (customResult.input !== undefined) {
335
+ customInput = customResult.input;
336
+ selectedOptions = [];
337
+ }
338
+ // If editor was dismissed (undefined), keep prior selectedOptions/customInput intact
336
339
  }
337
- selectedOptions = [];
338
340
  } else {
339
341
  selectedOptions = [stripRecommendedSuffix(choice)];
340
342
  customInput = undefined;
@@ -344,7 +346,7 @@ async function askSingleQuestion(
344
346
  }
345
347
  }
346
348
 
347
- if (timedOut && selectedOptions.length === 0 && !customInput) {
349
+ if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
348
350
  selectedOptions = getAutoSelectionOnTimeout(optionLabels, recommended);
349
351
  }
350
352
 
@@ -352,7 +354,7 @@ async function askSingleQuestion(
352
354
  }
353
355
 
354
356
  function formatQuestionResult(result: QuestionResult): string {
355
- if (result.customInput) {
357
+ if (result.customInput !== undefined) {
356
358
  return `${result.id}: "${result.customInput}"`;
357
359
  }
358
360
  if (result.selectedOptions.length > 0) {
@@ -415,7 +417,8 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
415
417
  const extensionUi = context.ui;
416
418
  const ui: UIContext = {
417
419
  select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions),
418
- input: (prompt, dialogOptions) => extensionUi.input(prompt, undefined, dialogOptions),
420
+ editor: (title, prefill, dialogOptions, editorOptions) =>
421
+ extensionUi.editor(title, prefill, dialogOptions, editorOptions),
419
422
  };
420
423
 
421
424
  // Determine timeout based on settings and plan mode
@@ -467,7 +470,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
467
470
  const [q] = params.questions;
468
471
  const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
469
472
 
470
- if (!timedOut && (cancelled || (selectedOptions.length === 0 && !customInput))) {
473
+ if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
471
474
  context.abort();
472
475
  throw new ToolAbortError("Ask tool was cancelled by the user");
473
476
  }
@@ -479,16 +482,23 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
479
482
  customInput,
480
483
  };
481
484
 
482
- let responseText: string;
483
- if (customInput) {
484
- responseText = `User provided custom input: ${customInput}`;
485
- } else if (selectedOptions.length > 0) {
486
- responseText = q.multi
487
- ? `User selected: ${selectedOptions.join(", ")}`
488
- : `User selected: ${selectedOptions[0]}`;
489
- } else {
490
- responseText = "User cancelled the selection";
485
+ const responseParts: string[] = [];
486
+ if (selectedOptions.length > 0) {
487
+ responseParts.push(
488
+ q.multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`,
489
+ );
491
490
  }
491
+ if (customInput !== undefined) {
492
+ responseParts.push(
493
+ customInput.includes("\n")
494
+ ? `User provided custom input:\n${customInput
495
+ .split("\n")
496
+ .map(line => ` ${line}`)
497
+ .join("\n")}`
498
+ : `User provided custom input: ${customInput}`,
499
+ );
500
+ }
501
+ const responseText = responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection";
492
502
 
493
503
  return { content: [{ type: "text" as const, text: responseText }], details };
494
504
  }
@@ -570,6 +580,25 @@ interface AskRenderArgs {
570
580
  }>;
571
581
  }
572
582
 
583
+ /** Render custom input as a single block with continuation lines (not one entry per line) */
584
+ function renderCustomInput(
585
+ uiTheme: Theme,
586
+ prefix: string,
587
+ customInput: string,
588
+ isLastEntry: boolean,
589
+ includeLeadingNewline = true,
590
+ ): string {
591
+ const lines = customInput.split("\n");
592
+ const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch;
593
+ const firstLine = lines[0] ?? "";
594
+ let text = `${includeLeadingNewline ? "\n" : ""}${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`;
595
+ const continuationIndent = isLastEntry ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
596
+ for (let i = 1; i < lines.length; i++) {
597
+ text += `\n${prefix}${continuationIndent} ${uiTheme.fg("toolOutput", lines[i])}`;
598
+ }
599
+ return text;
600
+ }
601
+
573
602
  export const askToolRenderer = {
574
603
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
575
604
  const label = formatTitle("Ask", uiTheme);
@@ -658,7 +687,7 @@ export const askToolRenderer = {
658
687
  // Multi-part results
659
688
  if (details.results && details.results.length > 0) {
660
689
  const hasAnySelection = details.results.some(
661
- r => r.customInput || (r.selectedOptions && r.selectedOptions.length > 0),
690
+ r => r.customInput !== undefined || (r.selectedOptions && r.selectedOptions.length > 0),
662
691
  );
663
692
  const header = renderStatusLine(
664
693
  {
@@ -676,7 +705,7 @@ export const askToolRenderer = {
676
705
  const isLastQuestion = i === details.results.length - 1;
677
706
  const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch;
678
707
  const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
679
- const hasSelection = r.customInput || r.selectedOptions.length > 0;
708
+ const hasSelection = r.customInput !== undefined || r.selectedOptions.length > 0;
680
709
  const statusIcon = hasSelection
681
710
  ? uiTheme.styledSymbol("status.success", "success")
682
711
  : uiTheme.styledSymbol("status.warning", "warning");
@@ -686,23 +715,30 @@ export const askToolRenderer = {
686
715
  );
687
716
  container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
688
717
 
689
- let answerText = "";
690
- if (r.customInput) {
691
- answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
692
- } else if (r.selectedOptions.length > 0) {
693
- for (let j = 0; j < r.selectedOptions.length; j++) {
694
- const isLast = j === r.selectedOptions.length - 1;
695
- const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
696
- const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
697
- uiTheme.fg("toolOutput", t),
698
- );
699
- answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
700
- }
701
- } else {
702
- answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
718
+ const answerLines: string[] = [];
719
+ for (let j = 0; j < r.selectedOptions.length; j++) {
720
+ const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
721
+ const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
722
+ const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
723
+ uiTheme.fg("toolOutput", t),
724
+ );
725
+ answerLines.push(
726
+ `${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
727
+ );
728
+ }
729
+ if (answerLines.length > 0) {
730
+ container.addChild(new Text(answerLines.join("\n"), 0, 0));
703
731
  }
704
- if (answerText) {
705
- container.addChild(new Text(answerText, 0, 0));
732
+ if (r.customInput !== undefined) {
733
+ container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0));
734
+ } else if (r.selectedOptions.length === 0) {
735
+ container.addChild(
736
+ new Text(
737
+ `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
738
+ 0,
739
+ 0,
740
+ ),
741
+ );
706
742
  }
707
743
  }
708
744
  return container;
@@ -715,28 +751,40 @@ export const askToolRenderer = {
715
751
  return new Text(fallback, 0, 0);
716
752
  }
717
753
 
718
- const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
754
+ const hasSelection =
755
+ details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0);
719
756
  const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
720
757
  const container = new Container();
721
758
  container.addChild(new Text(header, 0, 0));
722
759
  container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
723
760
 
724
- let answerText = "";
725
- if (details.customInput) {
726
- answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
727
- } else if (details.selectedOptions && details.selectedOptions.length > 0) {
761
+ const answerLines: string[] = [];
762
+ if (details.selectedOptions && details.selectedOptions.length > 0) {
728
763
  for (let i = 0; i < details.selectedOptions.length; i++) {
729
- const isLast = i === details.selectedOptions.length - 1;
764
+ const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined;
730
765
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
731
766
  const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
732
767
  uiTheme.fg("toolOutput", t),
733
768
  );
734
- answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
769
+ answerLines.push(
770
+ ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
771
+ );
735
772
  }
736
- } else {
737
- answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
738
773
  }
739
- container.addChild(new Text(answerText, 0, 0));
774
+ if (answerLines.length > 0) {
775
+ container.addChild(new Text(answerLines.join("\n"), 0, 0));
776
+ }
777
+ if (details.customInput !== undefined) {
778
+ container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0));
779
+ } else if (!details.selectedOptions || details.selectedOptions.length === 0) {
780
+ container.addChild(
781
+ new Text(
782
+ ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
783
+ 0,
784
+ 0,
785
+ ),
786
+ );
787
+ }
740
788
 
741
789
  return container;
742
790
  },
@@ -435,18 +435,6 @@ export const astEditToolRenderer = {
435
435
  group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
436
436
  );
437
437
 
438
- const getCollapsedChangeLimit = (groups: string[][], maxLines: number): number => {
439
- if (groups.length === 0) return 0;
440
- let usedLines = 0;
441
- let count = 0;
442
- for (const group of groups) {
443
- if (count > 0 && usedLines + group.length > maxLines) break;
444
- usedLines += group.length;
445
- count += 1;
446
- if (usedLines >= maxLines) break;
447
- }
448
- return count;
449
- };
450
438
  const badge = { label: "proposed", color: "warning" as const };
451
439
  const header = renderStatusLine(
452
440
  { icon: limitReached ? "warning" : "success", title: "AST Edit", description, badge, meta },
@@ -471,14 +459,12 @@ export const astEditToolRenderer = {
471
459
  const { expanded } = options;
472
460
  const key = new Hasher().bool(expanded).u32(width).digest();
473
461
  if (cached?.key === key) return cached.lines;
474
- const maxCollapsed = expanded
475
- ? changeGroups.length
476
- : getCollapsedChangeLimit(changeGroups, COLLAPSED_CHANGE_LIMIT);
477
462
  const changeLines = renderTreeList(
478
463
  {
479
464
  items: changeGroups,
480
465
  expanded,
481
- maxCollapsed,
466
+ maxCollapsed: changeGroups.length,
467
+ maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
482
468
  itemType: "change",
483
469
  renderItem: group =>
484
470
  group.map(line => {
@@ -402,19 +402,6 @@ export const astGrepToolRenderer = {
402
402
  group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
403
403
  );
404
404
 
405
- const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
406
- if (groups.length === 0) return 0;
407
- let usedLines = 0;
408
- let count = 0;
409
- for (const group of groups) {
410
- if (count > 0 && usedLines + group.length > maxLines) break;
411
- usedLines += group.length;
412
- count += 1;
413
- if (usedLines >= maxLines) break;
414
- }
415
- return count;
416
- };
417
-
418
405
  const extraLines: string[] = [];
419
406
  if (limitReached) {
420
407
  extraLines.push(uiTheme.fg("warning", "limit reached; narrow path pattern or increase limit"));
@@ -434,14 +421,12 @@ export const astGrepToolRenderer = {
434
421
  const { expanded } = options;
435
422
  const key = new Hasher().bool(expanded).u32(width).digest();
436
423
  if (cached?.key === key) return cached.lines;
437
- const maxCollapsed = expanded
438
- ? matchGroups.length
439
- : getCollapsedMatchLimit(matchGroups, COLLAPSED_MATCH_LIMIT);
440
424
  const matchLines = renderTreeList(
441
425
  {
442
426
  items: matchGroups,
443
427
  expanded,
444
- maxCollapsed,
428
+ maxCollapsed: matchGroups.length,
429
+ maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
445
430
  itemType: "match",
446
431
  renderItem: group =>
447
432
  group.map(line => {
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { Readability } from "@mozilla/readability";
@@ -18,9 +19,10 @@ import type {
18
19
  import { renderPromptTemplate } from "../config/prompt-templates";
19
20
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
20
21
  import type { ToolSession } from "../sdk";
21
- import { formatDimensionNote, resizeImage } from "../utils/image-resize";
22
+ import { resizeImage } from "../utils/image-resize";
22
23
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
23
24
  import type { OutputMeta } from "./output-meta";
25
+ import { expandPath, resolveToCwd } from "./path-utils";
24
26
  import stealthTamperingScript from "./puppeteer/00_stealth_tampering.txt" with { type: "text" };
25
27
  import stealthActivityScript from "./puppeteer/01_stealth_activity.txt" with { type: "text" };
26
28
  import stealthHairlineScript from "./puppeteer/02_stealth_hairline.txt" with { type: "text" };
@@ -35,6 +37,7 @@ import stealthPluginsScript from "./puppeteer/10_stealth_plugins.txt" with { typ
35
37
  import stealthHardwareScript from "./puppeteer/11_stealth_hardware.txt" with { type: "text" };
36
38
  import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type: "text" };
37
39
  import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
40
+ import { formatScreenshot } from "./render-utils";
38
41
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
39
42
  import { toolResult } from "./tool-result";
40
43
  import { clampTimeout } from "./tool-timeouts";
@@ -1364,23 +1367,38 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1364
1367
  { type: "image", data: buffer.toBase64(), mimeType: "image/png" },
1365
1368
  { maxBytes: 0.75 * 1024 * 1024 },
1366
1369
  );
1367
- const dimensionNote = formatDimensionNote(resized);
1368
- const tempFile = path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`);
1369
- await Bun.write(tempFile, resized.buffer);
1370
- details.screenshotPath = tempFile;
1371
- details.mimeType = resized.mimeType;
1372
- details.bytes = resized.buffer.length;
1373
-
1374
- // Show both raw bytes (saved to disk) and compressed bytes (sent to model).
1375
- const lines = [
1376
- "Screenshot captured",
1377
- `Format: ${resized.mimeType} (${(resized.buffer.length / 1024).toFixed(2)} KB)`,
1378
- `Dimensions: ${resized.width}x${resized.height}`,
1379
- ];
1380
- if (dimensionNote) {
1381
- lines.push(dimensionNote);
1370
+ // Resolve destination: user-defined path > screenshotDir (auto-named) > temp file.
1371
+ const screenshotDir = (() => {
1372
+ const v = this.session.settings.get("browser.screenshotDir") as string | undefined;
1373
+ return v ? expandPath(v) : undefined;
1374
+ })();
1375
+ const paramPath = params.path ? resolveToCwd(params.path as string, this.session.cwd) : undefined;
1376
+ let dest: string;
1377
+ if (paramPath) {
1378
+ dest = paramPath;
1379
+ } else if (screenshotDir) {
1380
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1);
1381
+ dest = path.join(screenshotDir, `screenshot-${ts}.png`);
1382
+ } else {
1383
+ dest = path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`);
1382
1384
  }
1383
-
1385
+ await fs.mkdir(path.dirname(dest), { recursive: true });
1386
+ // Full-res buffer when saving to a user-defined location; resized (API copy) for temp-only.
1387
+ const saveFullRes = !!(paramPath || screenshotDir);
1388
+ const savedBuffer = saveFullRes ? buffer : resized.buffer;
1389
+ const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
1390
+ await Bun.write(dest, savedBuffer);
1391
+ details.screenshotPath = dest;
1392
+ details.mimeType = savedMimeType;
1393
+ details.bytes = savedBuffer.length;
1394
+
1395
+ const lines = formatScreenshot({
1396
+ saveFullRes,
1397
+ savedMimeType,
1398
+ savedByteLength: savedBuffer.length,
1399
+ dest,
1400
+ resized,
1401
+ });
1384
1402
  return toolResult(details)
1385
1403
  .content([
1386
1404
  { type: "text", text: lines.join("\n") },
package/src/tools/find.ts CHANGED
@@ -261,6 +261,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
261
261
  gitignore: useGitignore,
262
262
  },
263
263
  onMatch,
264
+ this.session.searchDb,
264
265
  ),
265
266
  );
266
267
 
package/src/tools/grep.ts CHANGED
@@ -169,23 +169,27 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
169
169
  // Run grep
170
170
  let result: GrepResult;
171
171
  try {
172
- result = await grep({
173
- pattern: normalizedPattern,
174
- path: searchPath,
175
- glob: globFilter,
176
- type: type?.trim() || undefined,
177
- ignoreCase,
178
- multiline: effectiveMultiline,
179
- hidden: true,
180
- gitignore: useGitignore,
181
- cache: false,
182
- maxCount: internalLimit,
183
- offset: normalizedOffset > 0 ? normalizedOffset : undefined,
184
- contextBefore: normalizedContextBefore,
185
- contextAfter: normalizedContextAfter,
186
- maxColumns: DEFAULT_MAX_COLUMN,
187
- mode: effectiveOutputMode,
188
- });
172
+ result = await grep(
173
+ {
174
+ pattern: normalizedPattern,
175
+ path: searchPath,
176
+ glob: globFilter,
177
+ type: type?.trim() || undefined,
178
+ ignoreCase,
179
+ multiline: effectiveMultiline,
180
+ hidden: true,
181
+ gitignore: useGitignore,
182
+ cache: false,
183
+ maxCount: internalLimit,
184
+ offset: normalizedOffset > 0 ? normalizedOffset : undefined,
185
+ contextBefore: normalizedContextBefore,
186
+ contextAfter: normalizedContextAfter,
187
+ maxColumns: DEFAULT_MAX_COLUMN,
188
+ mode: effectiveOutputMode,
189
+ },
190
+ undefined,
191
+ this.session.searchDb,
192
+ );
189
193
  } catch (err) {
190
194
  if (err instanceof Error && err.message.startsWith("regex parse error")) {
191
195
  throw new ToolError(err.message);
@@ -457,6 +461,7 @@ export const grepToolRenderer = {
457
461
  items: lines,
458
462
  expanded,
459
463
  maxCollapsed: COLLAPSED_TEXT_LIMIT,
464
+ maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
460
465
  itemType: "item",
461
466
  renderItem: line => uiTheme.fg("toolOutput", line),
462
467
  },
@@ -522,19 +527,6 @@ export const grepToolRenderer = {
522
527
  }
523
528
  }
524
529
 
525
- const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
526
- if (groups.length === 0) return 0;
527
- let usedLines = 0;
528
- let count = 0;
529
- for (const group of groups) {
530
- if (count > 0 && usedLines + group.length > maxLines) break;
531
- usedLines += group.length;
532
- count += 1;
533
- if (usedLines >= maxLines) break;
534
- }
535
- return count;
536
- };
537
-
538
530
  const truncationReasons: string[] = [];
539
531
  if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`);
540
532
  if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
@@ -551,14 +543,13 @@ export const grepToolRenderer = {
551
543
  const { expanded } = options;
552
544
  const key = new Hasher().bool(expanded).u32(width).digest();
553
545
  if (cached?.key === key) return cached.lines;
554
- const maxCollapsed = expanded
555
- ? matchGroups.length
556
- : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
546
+ const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
557
547
  const matchLines = renderTreeList(
558
548
  {
559
549
  items: matchGroups,
560
550
  expanded,
561
- maxCollapsed,
551
+ maxCollapsed: matchGroups.length,
552
+ maxCollapsedLines: collapsedMatchLineBudget,
562
553
  itemType: "match",
563
554
  renderItem: group =>
564
555
  group.map(line => {
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { SearchDb } from "@oh-my-pi/pi-natives";
2
3
  import { $env, logger } from "@oh-my-pi/pi-utils";
3
4
  import type { AsyncJobManager } from "../async";
4
5
  import type { PromptTemplate } from "../config/prompt-templates";
@@ -144,6 +145,8 @@ export interface ToolSession {
144
145
  asyncJobManager?: AsyncJobManager;
145
146
  /** Settings instance for passing to subagents */
146
147
  settings: Settings;
148
+ /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
149
+ searchDb?: SearchDb;
147
150
  /** Plan mode state (if active) */
148
151
  getPlanModeState?: () => PlanModeState | undefined;
149
152
  /** Get compact conversation context for subagents (excludes tool results, system prompts) */
@@ -102,9 +102,16 @@ export function expandPath(filePath: string): string {
102
102
  /**
103
103
  * Resolve a path relative to the given cwd.
104
104
  * Handles ~ expansion and absolute paths.
105
+ *
106
+ * A bare root slash is treated as a workspace-root alias for tool inputs. Users
107
+ * often pass `/` to mean “search from here”, and letting tools escape to the
108
+ * filesystem root is almost never what they intended.
105
109
  */
106
110
  export function resolveToCwd(filePath: string, cwd: string): string {
107
111
  const expanded = expandPath(filePath);
112
+ if (/^\/+$/.test(expanded)) {
113
+ return cwd;
114
+ }
108
115
  if (path.isAbsolute(expanded)) {
109
116
  return expanded;
110
117
  }
@@ -180,7 +180,8 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
180
180
  // Clamp to reasonable range: 1s - 600s (10 min)
181
181
  const timeoutSec = clampTimeout("python", rawTimeout);
182
182
  const timeoutMs = timeoutSec * 1000;
183
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
183
+ const deadlineMs = Date.now() + timeoutMs;
184
+ const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now()));
184
185
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
185
186
  let outputSink: OutputSink | undefined;
186
187
  let outputSummary: OutputSummary | undefined;
@@ -267,7 +268,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
267
268
  const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
268
269
  const baseExecutorOptions: Omit<PythonExecutorOptions, "reset"> = {
269
270
  cwd: commandCwd,
270
- timeoutMs,
271
+ deadlineMs,
271
272
  signal: combinedSignal,
272
273
  sessionId,
273
274
  kernelMode: this.session.settings.get("python.kernelMode"),