@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.16.1] - 2026-03-27
6
+
7
+ ### Added
8
+
9
+ - Added `searchDb` parameter to `PromptActionAutocompleteProvider` constructor for native search database integration in autocomplete workflows
10
+ - Added `searchDb` parameter to enable native search database integration for grep and find operations
11
+ - Exported `SearchDb` type from tools module for type-safe search database usage
12
+
13
+ ### Changed
14
+
15
+ - Updated grep tool to accept and utilize `searchDb` parameter for improved search performance
16
+ - Updated find tool to pass `searchDb` parameter to underlying search operations
17
+ - Updated grep tool description to remove ripgrep-specific implementation detail
18
+
19
+ ## [13.16.0] - 2026-03-27
20
+ ### Added
21
+
22
+ - Implemented root path alias: bare `/` in tool inputs now resolves to the session working directory instead of the filesystem root
23
+ - Added `browser.screenshotDir` setting to configure screenshot save directory with path expansion
24
+
25
+ ### Changed
26
+
27
+ - Improved hashline tool documentation with clearer guidance on block boundary handling and closing delimiter duplication prevention
28
+ - Updated screenshot path resolution to use `resolveToCwd` for consistent workspace-relative path handling
29
+ - Updated hook editor hint text to include `ctrl+g external editor` option when using prompt style
30
+ - Refactored question result formatting to consistently include question ID in output
31
+
5
32
  ## [13.15.3] - 2026-03-26
6
33
 
7
34
  ### Added
@@ -21,6 +48,8 @@
21
48
 
22
49
  ### Added
23
50
 
51
+ - Added custom model roles/tags via config YAML
52
+ - Added ability to reorder model role/tag cycling via config YAML
24
53
  - Added prompt for tradeoff metrics during autoresearch setup to collect secondary metrics alongside primary metric
25
54
  - Added validation of contract path specifications to reject absolute paths and parent directory references
26
55
  - Added stricter benchmark command validation in `isAutoresearchShCommand()` to reject chained commands, pipes, and redirects
@@ -74,22 +103,10 @@
74
103
  - Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp`
75
104
  - Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication
76
105
  - Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery
106
+ - Added multiline custom input for `ask` custom answers, using the prompt-style editor without inactivity timeout while composing ([#506](https://github.com/can1357/oh-my-pi/issues/506))
77
107
 
78
108
  ### Changed
79
109
 
80
- - Changed `isAutoresearchShCommand()` to use proper command-line argument parsing instead of regex, improving accuracy for complex shell invocations
81
- - Changed autoresearch initialization prompt to display collected tradeoff metrics in the setup summary
82
- - Changed `command-initialize.md` template to include guidance on preflight requirements, comparability invariants, and marking measurement-critical files as off-limits
83
- - Changed `command-initialize.md` to instruct users to write or update `autoresearch.program.md` with durable heuristics and repo-specific strategy
84
- - Changed autoresearch resume guidance to emphasize continuing on the current protected branch rather than switching branches
85
- - Changed autoresearch prompt to clarify that `autoresearch.md` holds durable conclusions while `autoresearch.ideas.md` is the scratch backlog
86
- - Changed autoresearch prompt guidance to require stable measurement harness and fixed benchmark inputs unless intentionally starting a new segment
87
- - Changed autoresearch prompt to recommend keeping equal or near-equal results when they materially simplify implementation
88
- - Changed `init_experiment` to reset pending run state (checks, duration, ASI, artifact directory) when initializing a new segment
89
- - Changed `log_experiment` to set `autoResumeArmed` flag after successfully logging a run to enable auto-resume on next agent turn
90
- - Changed `run_experiment` to set `autoResumeArmed` flag and update dashboard after completing a run
91
- - Changed auto-resume logic to only prompt when a new pending run exists or when `autoResumeArmed` is explicitly set, preventing duplicate prompts
92
- - Changed path normalization in contract validation to use `path.posix.normalize()` for consistent path handling
93
110
  - Changed autoresearch initialization to collect and validate benchmark command, metric definition, scope paths, off-limits list, and constraints before `init_experiment`
94
111
  - Changed `init_experiment` to require exact benchmark command, metric definition, scope, off-limits, and constraints matching collected contract
95
112
  - Changed `log_experiment` to record run number, benchmark command, scope paths, off-limits list, constraints, and segment fingerprint with each result
@@ -139,9 +156,6 @@
139
156
 
140
157
  ### Fixed
141
158
 
142
- - Fixed boundary duplication warnings to always display when replacement lines match the next surviving line, even when auto-correction is disabled
143
- - Fixed secondary metrics validation to properly reject missing configured metrics and new metrics without force flag
144
- - Fixed ASI data cloning to prevent prototype pollution attacks by filtering reserved property names
145
159
  - Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions
146
160
  - Fixed dashboard overlay to display when running experiment even with zero completed results
147
161
  - Fixed tab character rendering in dashboard command display and tool output summaries
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.15.3",
4
+ "version": "13.16.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -42,12 +42,12 @@
42
42
  "dependencies": {
43
43
  "@agentclientprotocol/sdk": "0.16.1",
44
44
  "@mozilla/readability": "^0.6",
45
- "@oh-my-pi/omp-stats": "13.15.3",
46
- "@oh-my-pi/pi-agent-core": "13.15.3",
47
- "@oh-my-pi/pi-ai": "13.15.3",
48
- "@oh-my-pi/pi-natives": "13.15.3",
49
- "@oh-my-pi/pi-tui": "13.15.3",
50
- "@oh-my-pi/pi-utils": "13.15.3",
45
+ "@oh-my-pi/omp-stats": "13.16.1",
46
+ "@oh-my-pi/pi-agent-core": "13.16.1",
47
+ "@oh-my-pi/pi-ai": "13.16.1",
48
+ "@oh-my-pi/pi-natives": "13.16.1",
49
+ "@oh-my-pi/pi-tui": "13.16.1",
50
+ "@oh-my-pi/pi-utils": "13.16.1",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@xterm/headless": "^6.0",
53
53
  "ajv": "^8.18",
@@ -43,6 +43,7 @@ function buildToolSession(
43
43
  settings: options.settings,
44
44
  authStorage: options.authStorage,
45
45
  modelRegistry: options.modelRegistry,
46
+ searchDb: ctx.searchDb,
46
47
  };
47
48
  }
48
49
 
@@ -28,8 +28,9 @@ import {
28
28
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
29
29
  import { type Static, Type } from "@sinclair/typebox";
30
30
  import { type ConfigError, ConfigFile } from "../config";
31
- import type { ThemeColor } from "../modes/theme/theme";
31
+ import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
32
32
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
33
+ import type { Settings } from "./settings";
33
34
 
34
35
  export const kNoAuth = "N/A";
35
36
 
@@ -57,6 +58,53 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
57
58
 
58
59
  export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "commit", "task"];
59
60
 
61
+ /** Alias for ModelRoleInfo - used for both built-in and custom roles */
62
+ export type RoleInfo = ModelRoleInfo;
63
+
64
+ /**
65
+ * Return the canonical set of known roles for selector/carousel UI.
66
+ *
67
+ * Built-ins always come first. Configured cycle order, model assignments, and
68
+ * tag metadata can introduce additional custom roles without requiring duplicate
69
+ * entries across settings.
70
+ */
71
+ export function getKnownRoleIds(settings: Settings): string[] {
72
+ const roles = [...MODEL_ROLE_IDS] as string[];
73
+ const seen = new Set<string>(roles);
74
+ const addRole = (role: string) => {
75
+ if (seen.has(role)) return;
76
+ seen.add(role);
77
+ roles.push(role);
78
+ };
79
+
80
+ for (const role of settings.get("cycleOrder")) addRole(role);
81
+ for (const role of Object.keys(settings.getModelRoles())) addRole(role);
82
+ for (const role of Object.keys(settings.get("modelTags"))) addRole(role);
83
+
84
+ return roles;
85
+ }
86
+
87
+ /**
88
+ * Get role info for a role name (built-in or custom).
89
+ * Configured metadata overrides built-in defaults when present.
90
+ */
91
+ export function getRoleInfo(role: string, settings: Settings): RoleInfo {
92
+ const builtIn = role in MODEL_ROLES ? MODEL_ROLES[role as ModelRole] : undefined;
93
+ const configured = settings.get("modelTags")[role];
94
+
95
+ if (configured) {
96
+ return {
97
+ tag: builtIn?.tag,
98
+ name: configured.name || builtIn?.name || role,
99
+ color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
100
+ };
101
+ }
102
+
103
+ if (builtIn) return builtIn;
104
+
105
+ return { name: role, color: "muted" };
106
+ }
107
+
60
108
  const OpenRouterRoutingSchema = Type.Object({
61
109
  only: Type.Optional(Type.Array(Type.String())),
62
110
  order: Type.Optional(Type.Array(Type.String())),
@@ -357,7 +405,7 @@ export interface ProviderDiscoveryState {
357
405
 
358
406
  /** Result of loading custom models from models.json */
359
407
  interface CustomModelsResult {
360
- models?: Model<Api>[];
408
+ models?: CustomModelOverlay[];
361
409
  overrides?: Map<string, ProviderOverride>;
362
410
  modelOverrides?: Map<string, Map<string, ModelOverride>>;
363
411
  keylessProviders?: Set<string>;
@@ -552,6 +600,24 @@ interface CustomModelBuildOptions {
552
600
  useDefaults: boolean;
553
601
  }
554
602
 
603
+ type CustomModelOverlay = {
604
+ id: string;
605
+ provider: string;
606
+ api: Api;
607
+ baseUrl: string;
608
+ name?: string;
609
+ reasoning?: boolean;
610
+ thinking?: ThinkingConfig;
611
+ input?: ("text" | "image")[];
612
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
613
+ contextWindow?: number;
614
+ maxTokens?: number;
615
+ headers?: Record<string, string>;
616
+ compat?: Model<Api>["compat"];
617
+ contextPromotionTarget?: string;
618
+ premiumMultiplier?: number;
619
+ };
620
+
555
621
  function mergeCustomModelHeaders(
556
622
  providerHeaders: Record<string, string> | undefined,
557
623
  modelHeaders: Record<string, string> | undefined,
@@ -568,7 +634,7 @@ function mergeCustomModelHeaders(
568
634
  return headers;
569
635
  }
570
636
 
571
- function buildCustomModel(
637
+ function buildCustomModelOverlay(
572
638
  providerName: string,
573
639
  providerBaseUrl: string,
574
640
  providerApi: Api | undefined,
@@ -577,32 +643,84 @@ function buildCustomModel(
577
643
  authHeader: boolean | undefined,
578
644
  providerCompat: Model<Api>["compat"] | undefined,
579
645
  modelDef: CustomModelDefinitionLike,
580
- options: CustomModelBuildOptions,
581
- ): Model<Api> | undefined {
646
+ ): CustomModelOverlay | undefined {
582
647
  const api = modelDef.api ?? providerApi;
583
648
  if (!api) return undefined;
584
- const withDefaults = options.useDefaults;
585
- const cost = modelDef.cost ?? (withDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
586
- const input = modelDef.input ?? (withDefaults ? ["text"] : undefined);
587
- return enrichModelThinking({
649
+ return {
588
650
  id: modelDef.id,
589
- name: modelDef.name ?? (withDefaults ? modelDef.id : undefined),
590
- api,
591
651
  provider: providerName,
652
+ api,
592
653
  baseUrl: modelDef.baseUrl ?? providerBaseUrl,
593
- reasoning: modelDef.reasoning ?? (withDefaults ? false : undefined),
654
+ name: modelDef.name,
655
+ reasoning: modelDef.reasoning,
594
656
  thinking: modelDef.thinking as ThinkingConfig | undefined,
595
- input: input as ("text" | "image")[],
596
- cost,
597
- contextWindow: modelDef.contextWindow ?? (withDefaults ? 128000 : undefined),
598
- maxTokens: modelDef.maxTokens ?? (withDefaults ? 16384 : undefined),
657
+ input: modelDef.input as ("text" | "image")[] | undefined,
658
+ cost: modelDef.cost,
659
+ contextWindow: modelDef.contextWindow,
660
+ maxTokens: modelDef.maxTokens,
599
661
  headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
600
662
  compat: mergeCompat(providerCompat, modelDef.compat),
601
663
  contextPromotionTarget: modelDef.contextPromotionTarget,
602
664
  premiumMultiplier: modelDef.premiumMultiplier,
665
+ };
666
+ }
667
+
668
+ function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
669
+ if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
670
+ return model;
671
+ }
672
+ return { ...model, contextWindow: 1_000_000 };
673
+ }
674
+
675
+ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
676
+ const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model;
677
+ const cost =
678
+ resolvedModel.cost ?? (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
679
+ const input = resolvedModel.input ?? (options.useDefaults ? ["text"] : undefined);
680
+ return enrichModelThinking({
681
+ id: resolvedModel.id,
682
+ name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
683
+ api: resolvedModel.api,
684
+ provider: resolvedModel.provider,
685
+ baseUrl: resolvedModel.baseUrl,
686
+ reasoning: resolvedModel.reasoning ?? (options.useDefaults ? false : undefined),
687
+ thinking: resolvedModel.thinking,
688
+ input: input as ("text" | "image")[],
689
+ cost,
690
+ contextWindow: resolvedModel.contextWindow ?? (options.useDefaults ? 128000 : undefined),
691
+ maxTokens: resolvedModel.maxTokens ?? (options.useDefaults ? 16384 : undefined),
692
+ headers: resolvedModel.headers,
693
+ compat: resolvedModel.compat,
694
+ contextPromotionTarget: resolvedModel.contextPromotionTarget,
695
+ premiumMultiplier: resolvedModel.premiumMultiplier,
603
696
  } as Model<Api>);
604
697
  }
605
698
 
699
+ function buildCustomModel(
700
+ providerName: string,
701
+ providerBaseUrl: string,
702
+ providerApi: Api | undefined,
703
+ providerHeaders: Record<string, string> | undefined,
704
+ providerApiKey: string | undefined,
705
+ authHeader: boolean | undefined,
706
+ providerCompat: Model<Api>["compat"] | undefined,
707
+ modelDef: CustomModelDefinitionLike,
708
+ options: CustomModelBuildOptions,
709
+ ): Model<Api> | undefined {
710
+ const model = buildCustomModelOverlay(
711
+ providerName,
712
+ providerBaseUrl,
713
+ providerApi,
714
+ providerHeaders,
715
+ providerApiKey,
716
+ authHeader,
717
+ providerCompat,
718
+ modelDef,
719
+ );
720
+ if (!model) return undefined;
721
+ return finalizeCustomModel(model, options);
722
+ }
723
+
606
724
  /**
607
725
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
608
726
  */
@@ -611,6 +729,8 @@ export class ModelRegistry {
611
729
  #customProviderApiKeys: Map<string, string> = new Map();
612
730
  #keylessProviders: Set<string> = new Set();
613
731
  #discoverableProviders: DiscoveryProviderConfig[] = [];
732
+ #customModelOverlays: CustomModelOverlay[] = [];
733
+ #providerOverrides: Map<string, ProviderOverride> = new Map();
614
734
  #modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
615
735
  #configError: ConfigError | undefined = undefined;
616
736
  #modelsConfigFile: ConfigFile<ModelsConfig>;
@@ -677,6 +797,7 @@ export class ModelRegistry {
677
797
  this.#customProviderApiKeys.clear();
678
798
  this.#keylessProviders.clear();
679
799
  this.#discoverableProviders = [];
800
+ this.#providerOverrides.clear();
680
801
  this.#modelOverrides.clear();
681
802
  this.#configError = undefined;
682
803
  this.#providerDiscoveryStates.clear();
@@ -704,54 +825,83 @@ export class ModelRegistry {
704
825
  this.#configError = configError;
705
826
  this.#keylessProviders = keylessProviders;
706
827
  this.#discoverableProviders = discoverableProviders;
828
+ this.#customModelOverlays = customModels;
829
+ this.#providerOverrides = overrides;
707
830
  this.#modelOverrides = modelOverrides;
708
831
 
709
832
  this.#addImplicitDiscoverableProviders(configuredProviders);
710
- const builtInModels = this.#loadBuiltInModels(overrides, modelOverrides);
711
- const cachedDiscoveries = this.#loadCachedDiscoverableModels();
712
- const combined = this.#mergeCustomModels(builtInModels, [...customModels, ...cachedDiscoveries]);
833
+ const builtInModels = this.#applyHardcodedModelPolicies(this.#loadBuiltInModels(overrides));
834
+ const cachedDiscoveries = this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels());
835
+ const resolvedDefaults = this.#mergeResolvedModels(builtInModels, cachedDiscoveries);
836
+ const combined = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
713
837
 
714
- this.#models = this.#applyHardcodedModelPolicies(combined);
838
+ this.#models = this.#applyModelOverrides(combined, this.#modelOverrides);
715
839
  }
716
840
 
717
- /** Load built-in models, applying provider and per-model overrides */
718
- #loadBuiltInModels(
719
- overrides: Map<string, ProviderOverride>,
720
- modelOverrides: Map<string, Map<string, ModelOverride>>,
721
- ): Model<Api>[] {
841
+ /** Load built-in models, applying provider-level overrides only.
842
+ * Per-model overrides are applied later by #applyModelOverrides. */
843
+ #loadBuiltInModels(overrides: Map<string, ProviderOverride>): Model<Api>[] {
722
844
  return getBundledProviders().flatMap(provider => {
723
845
  const models = getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[];
724
846
  const providerOverride = overrides.get(provider);
725
- const perModelOverrides = modelOverrides.get(provider);
726
847
 
727
848
  return models.map(m => {
728
- let model = m;
729
- if (providerOverride) {
730
- model = {
731
- ...model,
732
- baseUrl: providerOverride.baseUrl ?? model.baseUrl,
733
- headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
734
- compat: mergeCompat(model.compat, providerOverride.compat),
735
- };
736
- }
737
- const modelOverride = perModelOverrides?.get(m.id);
738
- if (modelOverride) {
739
- model = applyModelOverride(model, modelOverride);
740
- }
741
- return model;
849
+ if (!providerOverride) return m;
850
+ return {
851
+ ...m,
852
+ baseUrl: providerOverride.baseUrl ?? m.baseUrl,
853
+ headers: providerOverride.headers ? { ...m.headers, ...providerOverride.headers } : m.headers,
854
+ compat: mergeCompat(m.compat, providerOverride.compat),
855
+ };
742
856
  });
743
857
  });
744
858
  }
745
859
 
860
+ #mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
861
+ const merged = [...baseModels];
862
+ for (const replacementModel of replacementModels) {
863
+ const existingIndex = merged.findIndex(
864
+ m => m.provider === replacementModel.provider && m.id === replacementModel.id,
865
+ );
866
+ if (existingIndex >= 0) {
867
+ merged[existingIndex] = replacementModel;
868
+ } else {
869
+ merged.push(replacementModel);
870
+ }
871
+ }
872
+ return merged;
873
+ }
874
+
746
875
  /** Merge custom models with built-in, replacing by provider+id match */
747
- #mergeCustomModels(builtInModels: Model<Api>[], customModels: Model<Api>[]): Model<Api>[] {
876
+ #mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): Model<Api>[] {
748
877
  const merged = [...builtInModels];
749
878
  for (const customModel of customModels) {
750
879
  const existingIndex = merged.findIndex(m => m.provider === customModel.provider && m.id === customModel.id);
751
880
  if (existingIndex >= 0) {
752
- merged[existingIndex] = customModel;
881
+ const existingModel = merged[existingIndex];
882
+ merged[existingIndex] = enrichModelThinking({
883
+ ...existingModel,
884
+ id: customModel.id,
885
+ provider: customModel.provider,
886
+ api: customModel.api,
887
+ baseUrl: customModel.baseUrl,
888
+ name: customModel.name ?? existingModel.name,
889
+ reasoning: customModel.reasoning ?? existingModel.reasoning,
890
+ thinking: customModel.thinking ?? existingModel.thinking,
891
+ input: customModel.input ?? existingModel.input,
892
+ cost: customModel.cost ?? existingModel.cost,
893
+ contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
894
+ maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
895
+ // Same-id custom definitions replace bundled transport behavior. Provider-level
896
+ // headers/compat were already folded into customModel during parsing; do not
897
+ // re-merge bundled transport metadata here.
898
+ headers: customModel.headers,
899
+ compat: customModel.compat,
900
+ contextPromotionTarget: customModel.contextPromotionTarget ?? existingModel.contextPromotionTarget,
901
+ premiumMultiplier: customModel.premiumMultiplier ?? existingModel.premiumMultiplier,
902
+ } as Model<Api>);
753
903
  } else {
754
- merged.push(customModel);
904
+ merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
755
905
  }
756
906
  }
757
907
  return merged;
@@ -936,22 +1086,31 @@ export class ModelRegistry {
936
1086
  if (discovered.length === 0) {
937
1087
  return;
938
1088
  }
939
- const merged = this.#mergeCustomModels(
940
- this.#models,
1089
+ const discoveredModels = this.#applyHardcodedModelPolicies(
941
1090
  discovered.map(model => {
942
- const existing =
943
- this.find(model.provider, model.id) ??
944
- this.#models.find(candidate => candidate.provider === model.provider);
945
- return existing
1091
+ const existing = this.find(model.provider, model.id);
1092
+ if (existing) {
1093
+ return {
1094
+ ...model,
1095
+ baseUrl: existing.baseUrl,
1096
+ headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
1097
+ };
1098
+ }
1099
+ const providerOverride = this.#providerOverrides.get(model.provider);
1100
+ return providerOverride
946
1101
  ? {
947
1102
  ...model,
948
- baseUrl: existing.baseUrl,
949
- headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
1103
+ baseUrl: providerOverride.baseUrl ?? model.baseUrl,
1104
+ headers: providerOverride.headers
1105
+ ? { ...model.headers, ...providerOverride.headers }
1106
+ : model.headers,
950
1107
  }
951
1108
  : model;
952
1109
  }),
953
1110
  );
954
- this.#models = this.#applyHardcodedModelPolicies(this.#applyModelOverrides(merged, this.#modelOverrides));
1111
+ const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
1112
+ const combined = this.#mergeCustomModels(resolved, this.#customModelOverlays);
1113
+ this.#models = this.#applyModelOverrides(combined, this.#modelOverrides);
955
1114
  }
956
1115
 
957
1116
  async #discoverProviderModels(
@@ -1455,8 +1614,8 @@ export class ModelRegistry {
1455
1614
  });
1456
1615
  }
1457
1616
 
1458
- #parseModels(config: ModelsConfig): Model<Api>[] {
1459
- const models: Model<Api>[] = [];
1617
+ #parseModels(config: ModelsConfig): CustomModelOverlay[] {
1618
+ const models: CustomModelOverlay[] = [];
1460
1619
 
1461
1620
  for (const [providerName, providerConfig] of Object.entries(config.providers)) {
1462
1621
  const modelDefs = providerConfig.models ?? [];
@@ -1465,7 +1624,7 @@ export class ModelRegistry {
1465
1624
  this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
1466
1625
  }
1467
1626
  for (const modelDef of modelDefs) {
1468
- const model = buildCustomModel(
1627
+ const model = buildCustomModelOverlay(
1469
1628
  providerName,
1470
1629
  providerConfig.baseUrl!,
1471
1630
  providerConfig.api as Api | undefined,
@@ -1474,7 +1633,6 @@ export class ModelRegistry {
1474
1633
  providerConfig.authHeader,
1475
1634
  providerConfig.compat,
1476
1635
  modelDef as CustomModelDefinitionLike,
1477
- { useDefaults: true },
1478
1636
  );
1479
1637
  if (!model) continue;
1480
1638
  models.push(model);
@@ -1636,7 +1794,7 @@ export class ModelRegistry {
1636
1794
  config.authHeader,
1637
1795
  config.compat,
1638
1796
  modelDef as CustomModelDefinitionLike,
1639
- { useDefaults: false },
1797
+ { useDefaults: true },
1640
1798
  );
1641
1799
  if (!model) {
1642
1800
  throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
@@ -135,10 +135,21 @@ type SettingDef =
135
135
  // Schema Definition
136
136
  // ═══════════════════════════════════════════════════════════════════════════
137
137
 
138
+ export interface ModelTagDef {
139
+ name: string;
140
+ color?: string;
141
+ }
142
+
143
+ export interface ModelTagsSettings {
144
+ [key: string]: ModelTagDef;
145
+ }
146
+
138
147
  // Typed defaults for array/record settings — named constants avoid `as` casts
139
148
  // under `as const` while still letting SettingValue infer the correct element type.
140
149
  const EMPTY_STRING_ARRAY: string[] = [];
141
150
  const EMPTY_STRING_RECORD: Record<string, string> = {};
151
+ const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
152
+ const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
142
153
  export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
143
154
  {
144
155
  pattern: "^\\s*(cat|head|tail|less|more)\\s+",
@@ -195,6 +206,10 @@ export const SETTINGS_SCHEMA = {
195
206
 
196
207
  modelRoles: { type: "record", default: EMPTY_STRING_RECORD },
197
208
 
209
+ modelTags: { type: "record", default: EMPTY_MODEL_TAGS_RECORD },
210
+
211
+ cycleOrder: { type: "array", default: DEFAULT_CYCLE_ORDER },
212
+
198
213
  // ────────────────────────────────────────────────────────────────────────
199
214
  // Appearance
200
215
  // ────────────────────────────────────────────────────────────────────────
@@ -1183,6 +1198,16 @@ export const SETTINGS_SCHEMA = {
1183
1198
  description: "Launch browser in headless mode (disable to show browser UI)",
1184
1199
  },
1185
1200
  },
1201
+ "browser.screenshotDir": {
1202
+ type: "string",
1203
+ default: undefined,
1204
+ ui: {
1205
+ tab: "tools",
1206
+ label: "Screenshot directory",
1207
+ description:
1208
+ "Directory to save screenshots. If unset, screenshots go to a temp file. Supports ~. Examples: ~/Downloads, ~/Desktop, /sdcard/Download (Android)",
1209
+ },
1210
+ },
1186
1211
 
1187
1212
  // Tool execution
1188
1213
  "tools.intentTracing": {
@@ -1767,6 +1792,8 @@ export interface GroupTypeMap {
1767
1792
  thinkingBudgets: ThinkingBudgetsSettings;
1768
1793
  stt: SttSettings;
1769
1794
  modelRoles: Record<string, string>;
1795
+ modelTags: ModelTagsSettings;
1796
+ cycleOrder: string[];
1770
1797
  }
1771
1798
 
1772
1799
  export type GroupPrefix = keyof GroupTypeMap;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
8
8
  import type { Model } from "@oh-my-pi/pi-ai";
9
+ import type { SearchDb } from "@oh-my-pi/pi-natives";
9
10
  import type { Component } from "@oh-my-pi/pi-tui";
10
11
  import type { Static, TSchema } from "@sinclair/typebox";
11
12
  import type { Rule } from "../../capability/rule";
@@ -71,6 +72,8 @@ export interface CustomToolContext {
71
72
  modelRegistry: ModelRegistry;
72
73
  /** Current model (may be undefined if no model is selected yet) */
73
74
  model: Model | undefined;
75
+ /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
76
+ searchDb?: SearchDb;
74
77
  /** Whether the agent is idle (not streaming) */
75
78
  isIdle(): boolean;
76
79
  /** Whether there are queued messages waiting to be processed */
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
5
5
  import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
6
+ import type { SearchDb } from "@oh-my-pi/pi-natives";
6
7
  import type { KeyId } from "@oh-my-pi/pi-tui";
7
8
  import { logger } from "@oh-my-pi/pi-utils";
8
9
  import type { ModelRegistry } from "../../config/model-registry";
@@ -160,6 +161,7 @@ export class ExtensionRunner {
160
161
  #uiContext: ExtensionUIContext;
161
162
  #errorListeners: Set<ExtensionErrorListener> = new Set();
162
163
  #getModel: () => Model | undefined = () => undefined;
164
+ #getSearchDbFn: () => SearchDb | undefined = () => undefined;
163
165
  #isIdleFn: () => boolean = () => true;
164
166
  #waitForIdleFn: () => Promise<void> = async () => {};
165
167
  #abortFn: () => void = () => {};
@@ -205,6 +207,7 @@ export class ExtensionRunner {
205
207
 
206
208
  // Context actions (required)
207
209
  this.#getModel = contextActions.getModel;
210
+ this.#getSearchDbFn = contextActions.getSearchDb ?? (() => undefined);
208
211
  this.#isIdleFn = contextActions.isIdle;
209
212
  this.#abortFn = contextActions.abort;
210
213
  this.#hasPendingMessagesFn = contextActions.hasPendingMessages;
@@ -376,6 +379,7 @@ export class ExtensionRunner {
376
379
 
377
380
  createContext(): ExtensionContext {
378
381
  const getModel = this.#getModel;
382
+ const getSearchDb = this.#getSearchDbFn;
379
383
  return {
380
384
  ui: this.#uiContext,
381
385
  getContextUsage: () => this.#getContextUsageFn(),
@@ -387,6 +391,9 @@ export class ExtensionRunner {
387
391
  get model() {
388
392
  return getModel();
389
393
  },
394
+ get searchDb() {
395
+ return getSearchDb();
396
+ },
390
397
  isIdle: () => this.#isIdleFn(),
391
398
  abort: () => this.#abortFn(),
392
399
  hasPendingMessages: () => this.#hasPendingMessagesFn(),
@@ -22,6 +22,7 @@ import type {
22
22
  ToolResultMessage,
23
23
  } from "@oh-my-pi/pi-ai";
24
24
  import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
25
+ import type { SearchDb } from "@oh-my-pi/pi-natives";
25
26
  import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
26
27
  import type { Static, TSchema } from "@sinclair/typebox";
27
28
  import type { Rule } from "../../capability/rule";
@@ -161,7 +162,12 @@ export interface ExtensionUIContext {
161
162
  getEditorText(): string;
162
163
 
163
164
  /** Show a multi-line editor for text editing. */
164
- editor(title: string, prefill?: string): Promise<string | undefined>;
165
+ editor(
166
+ title: string,
167
+ prefill?: string,
168
+ dialogOptions?: ExtensionUIDialogOptions,
169
+ editorOptions?: { promptStyle?: boolean },
170
+ ): Promise<string | undefined>;
165
171
 
166
172
  /** Set a custom editor component via factory function, or undefined to restore the default editor. */
167
173
  setEditorComponent(
@@ -224,6 +230,8 @@ export interface ExtensionContext {
224
230
  modelRegistry: ModelRegistry;
225
231
  /** Current model (may be undefined) */
226
232
  model: Model | undefined;
233
+ /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
234
+ searchDb?: SearchDb;
227
235
  /** Whether the agent is idle (not streaming) */
228
236
  isIdle(): boolean;
229
237
  /** Abort the current agent operation */
@@ -1290,6 +1298,7 @@ export interface ExtensionActions {
1290
1298
  /** Actions for ExtensionContext (ctx.* in event handlers). */
1291
1299
  export interface ExtensionContextActions {
1292
1300
  getModel: () => Model | undefined;
1301
+ getSearchDb?: () => SearchDb | undefined;
1293
1302
  isIdle: () => boolean;
1294
1303
  abort: () => void;
1295
1304
  hasPendingMessages: () => boolean;