@oh-my-pi/pi-coding-agent 13.15.2 → 13.16.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 (41) hide show
  1. package/CHANGELOG.md +26 -16
  2. package/package.json +7 -7
  3. package/src/config/keybindings.ts +6 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/extensions/types.ts +6 -1
  7. package/src/extensibility/hooks/types.ts +1 -1
  8. package/src/internal-urls/docs-index.generated.ts +1 -1
  9. package/src/modes/components/custom-editor.ts +6 -4
  10. package/src/modes/components/hook-editor.ts +57 -8
  11. package/src/modes/components/model-selector.ts +48 -29
  12. package/src/modes/components/settings-defs.ts +10 -1
  13. package/src/modes/components/settings-selector.ts +92 -5
  14. package/src/modes/controllers/extension-ui-controller.ts +32 -4
  15. package/src/modes/controllers/input-controller.ts +22 -9
  16. package/src/modes/controllers/selector-controller.ts +2 -2
  17. package/src/modes/interactive-mode.ts +7 -2
  18. package/src/modes/rpc/rpc-mode.ts +78 -30
  19. package/src/modes/rpc/rpc-types.ts +9 -1
  20. package/src/modes/theme/theme.ts +70 -0
  21. package/src/modes/types.ts +6 -1
  22. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  23. package/src/prompts/system/custom-system-prompt.md +5 -0
  24. package/src/prompts/system/system-prompt.md +6 -0
  25. package/src/prompts/tools/ask.md +1 -0
  26. package/src/prompts/tools/hashline.md +20 -5
  27. package/src/sdk.ts +9 -1
  28. package/src/session/agent-session.ts +338 -80
  29. package/src/session/messages.ts +23 -0
  30. package/src/session/session-manager.ts +65 -0
  31. package/src/system-prompt.ts +63 -2
  32. package/src/tools/ask.ts +109 -61
  33. package/src/tools/ast-edit.ts +2 -16
  34. package/src/tools/ast-grep.ts +2 -17
  35. package/src/tools/browser.ts +35 -17
  36. package/src/tools/grep.ts +4 -17
  37. package/src/tools/path-utils.ts +7 -0
  38. package/src/tools/render-utils.ts +27 -0
  39. package/src/tui/tree-list.ts +51 -22
  40. package/src/utils/image-input.ts +11 -1
  41. package/src/web/search/providers/codex.ts +10 -3
@@ -16,6 +16,57 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
16
16
  import { loadSkills, type Skill } from "./extensibility/skills";
17
17
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
18
18
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
19
+ import { formatPromptContent } from "./utils/prompt-format";
20
+
21
+ interface AlwaysApplyRule {
22
+ name: string;
23
+ content: string;
24
+ path: string;
25
+ }
26
+
27
+ function normalizePromptBlock(content: string): string {
28
+ return formatPromptContent(content, { renderPhase: "post-render" }).trim();
29
+ }
30
+
31
+ function splitComparablePromptBlocks(content: string | null | undefined): string[] {
32
+ const normalized = firstNonEmpty(content);
33
+ if (!normalized) return [];
34
+
35
+ return normalizePromptBlock(normalized)
36
+ .split(/\n{2,}/)
37
+ .map(block => block.trim())
38
+ .filter(block => block.length > 0);
39
+ }
40
+
41
+ function promptSourceContainsRule(source: string | null | undefined, ruleContent: string): boolean {
42
+ const sourceBlocks = splitComparablePromptBlocks(source);
43
+ const ruleBlocks = splitComparablePromptBlocks(ruleContent);
44
+ if (sourceBlocks.length === 0 || ruleBlocks.length === 0 || ruleBlocks.length > sourceBlocks.length) return false;
45
+
46
+ for (let start = 0; start <= sourceBlocks.length - ruleBlocks.length; start += 1) {
47
+ if (ruleBlocks.every((block, offset) => sourceBlocks[start + offset] === block)) return true;
48
+ }
49
+
50
+ return false;
51
+ }
52
+
53
+ function dedupeAlwaysApplyRules(
54
+ alwaysApplyRules: AlwaysApplyRule[] | undefined,
55
+ promptSources: Array<string | null | undefined>,
56
+ ): AlwaysApplyRule[] {
57
+ if (!alwaysApplyRules || alwaysApplyRules.length === 0) return [];
58
+
59
+ return alwaysApplyRules.filter(
60
+ rule => !promptSources.some(source => promptSourceContainsRule(source, rule.content)),
61
+ );
62
+ }
63
+
64
+ function dedupePromptSource(source: string | null | undefined, otherSources: Array<string | null | undefined>): string {
65
+ const resolvedSource = firstNonEmpty(source);
66
+ if (!resolvedSource) return "";
67
+
68
+ return otherSources.some(otherSource => promptSourceContainsRule(otherSource, resolvedSource)) ? "" : resolvedSource;
69
+ }
19
70
 
20
71
  function firstNonEmpty(...values: (string | undefined | null)[]): string | null {
21
72
  for (const value of values) {
@@ -379,6 +430,8 @@ export interface BuildSystemPromptOptions {
379
430
  mcpDiscoveryServerSummaries?: string[];
380
431
  /** Encourage the agent to delegate via tasks unless changes are trivial. */
381
432
  eagerTasks?: boolean;
433
+ /** Rules with alwaysApply=true — their full content is injected into the prompt. */
434
+ alwaysApplyRules?: AlwaysApplyRule[];
382
435
  }
383
436
 
384
437
  /** Build the system prompt with tools, guidelines, and context */
@@ -398,6 +451,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
398
451
  contextFiles: providedContextFiles,
399
452
  skills: providedSkills,
400
453
  rules,
454
+ alwaysApplyRules,
401
455
  intentField,
402
456
  mcpDiscoveryMode = false,
403
457
  mcpDiscoveryServerSummaries = [],
@@ -519,10 +573,16 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
519
573
  const hasRead = tools?.has("read");
520
574
  const filteredSkills = hasRead ? skills : [];
521
575
 
576
+ const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
577
+ resolvedCustomPrompt,
578
+ resolvedAppendPrompt,
579
+ ]);
580
+ const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
581
+ const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
582
+
522
583
  const environment = await logger.timeAsync("getEnvironmentInfo", getEnvironmentInfo);
523
584
  const data = {
524
- // Explicit custom prompts replace discovered SYSTEM.md content rather than layering it twice.
525
- systemPromptCustomization: resolvedCustomPrompt ? "" : (systemPromptCustomization ?? ""),
585
+ systemPromptCustomization: effectiveSystemPromptCustomization,
526
586
  customPrompt: resolvedCustomPrompt,
527
587
  appendPrompt: resolvedAppendPrompt ?? "",
528
588
  tools: toolNames,
@@ -533,6 +593,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
533
593
  agentsMdSearch,
534
594
  skills: filteredSkills,
535
595
  rules: rules ?? [],
596
+ alwaysApplyRules: injectedAlwaysApplyRules,
536
597
  date,
537
598
  dateTime,
538
599
  cwd: promptCwd,
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/grep.ts CHANGED
@@ -457,6 +457,7 @@ export const grepToolRenderer = {
457
457
  items: lines,
458
458
  expanded,
459
459
  maxCollapsed: COLLAPSED_TEXT_LIMIT,
460
+ maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
460
461
  itemType: "item",
461
462
  renderItem: line => uiTheme.fg("toolOutput", line),
462
463
  },
@@ -522,19 +523,6 @@ export const grepToolRenderer = {
522
523
  }
523
524
  }
524
525
 
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
526
  const truncationReasons: string[] = [];
539
527
  if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`);
540
528
  if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
@@ -551,14 +539,13 @@ export const grepToolRenderer = {
551
539
  const { expanded } = options;
552
540
  const key = new Hasher().bool(expanded).u32(width).digest();
553
541
  if (cached?.key === key) return cached.lines;
554
- const maxCollapsed = expanded
555
- ? matchGroups.length
556
- : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
542
+ const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
557
543
  const matchLines = renderTreeList(
558
544
  {
559
545
  items: matchGroups,
560
546
  expanded,
561
- maxCollapsed,
547
+ maxCollapsed: matchGroups.length,
548
+ maxCollapsedLines: collapsedMatchLineBudget,
562
549
  itemType: "match",
563
550
  renderItem: group =>
564
551
  group.map(line => {
@@ -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
  }
@@ -8,6 +8,7 @@ import * as os from "node:os";
8
8
  import { type Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui";
9
9
  import { getIndentation, pluralize } from "@oh-my-pi/pi-utils";
10
10
  import type { Theme } from "../modes/theme/theme";
11
+ import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
11
12
 
12
13
  export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui";
13
14
 
@@ -527,6 +528,32 @@ export function shortenPath(filePath: string, homeDir?: string): string {
527
528
  return filePath;
528
529
  }
529
530
 
531
+ export function formatScreenshot(opts: {
532
+ saveFullRes: boolean;
533
+ savedMimeType: string;
534
+ savedByteLength: number;
535
+ dest: string;
536
+ resized: ResizedImage;
537
+ }): string[] {
538
+ const lines = ["Screenshot captured"];
539
+ if (opts.saveFullRes) {
540
+ lines.push(
541
+ `Saved: ${opts.savedMimeType} (${(opts.savedByteLength / 1024).toFixed(2)} KB) to ${shortenPath(opts.dest)}`,
542
+ );
543
+ lines.push(
544
+ `Model: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB, ${opts.resized.width}x${opts.resized.height})`,
545
+ );
546
+ } else {
547
+ lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
548
+ lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
549
+ }
550
+ const dimensionNote = formatDimensionNote(opts.resized);
551
+ if (dimensionNote) {
552
+ lines.push(dimensionNote);
553
+ }
554
+ return lines;
555
+ }
556
+
530
557
  export function wrapBrackets(text: string, theme: Theme): string {
531
558
  return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
532
559
  }