@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.16.0] - 2026-03-27
6
+ ### Added
7
+
8
+ - Implemented root path alias: bare `/` in tool inputs now resolves to the session working directory instead of the filesystem root
9
+ - Added `browser.screenshotDir` setting to configure screenshot save directory with path expansion
10
+
11
+ ### Changed
12
+
13
+ - Improved hashline tool documentation with clearer guidance on block boundary handling and closing delimiter duplication prevention
14
+ - Updated screenshot path resolution to use `resolveToCwd` for consistent workspace-relative path handling
15
+ - Updated hook editor hint text to include `ctrl+g external editor` option when using prompt style
16
+ - Refactored question result formatting to consistently include question ID in output
17
+
18
+ ## [13.15.3] - 2026-03-26
19
+
20
+ ### Added
21
+
22
+ - Added configurable `app.model.selectTemporary` keybinding for temporary model selection.
23
+
5
24
  ## [13.15.0] - 2026-03-23
6
25
  ### Breaking Changes
7
26
 
@@ -15,6 +34,8 @@
15
34
 
16
35
  ### Added
17
36
 
37
+ - Added custom model roles/tags via config YAML
38
+ - Added ability to reorder model role/tag cycling via config YAML
18
39
  - Added prompt for tradeoff metrics during autoresearch setup to collect secondary metrics alongside primary metric
19
40
  - Added validation of contract path specifications to reject absolute paths and parent directory references
20
41
  - Added stricter benchmark command validation in `isAutoresearchShCommand()` to reject chained commands, pipes, and redirects
@@ -68,22 +89,10 @@
68
89
  - Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp`
69
90
  - Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication
70
91
  - Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery
92
+ - 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))
71
93
 
72
94
  ### Changed
73
95
 
74
- - Changed `isAutoresearchShCommand()` to use proper command-line argument parsing instead of regex, improving accuracy for complex shell invocations
75
- - Changed autoresearch initialization prompt to display collected tradeoff metrics in the setup summary
76
- - Changed `command-initialize.md` template to include guidance on preflight requirements, comparability invariants, and marking measurement-critical files as off-limits
77
- - Changed `command-initialize.md` to instruct users to write or update `autoresearch.program.md` with durable heuristics and repo-specific strategy
78
- - Changed autoresearch resume guidance to emphasize continuing on the current protected branch rather than switching branches
79
- - Changed autoresearch prompt to clarify that `autoresearch.md` holds durable conclusions while `autoresearch.ideas.md` is the scratch backlog
80
- - Changed autoresearch prompt guidance to require stable measurement harness and fixed benchmark inputs unless intentionally starting a new segment
81
- - Changed autoresearch prompt to recommend keeping equal or near-equal results when they materially simplify implementation
82
- - Changed `init_experiment` to reset pending run state (checks, duration, ASI, artifact directory) when initializing a new segment
83
- - Changed `log_experiment` to set `autoResumeArmed` flag after successfully logging a run to enable auto-resume on next agent turn
84
- - Changed `run_experiment` to set `autoResumeArmed` flag and update dashboard after completing a run
85
- - Changed auto-resume logic to only prompt when a new pending run exists or when `autoResumeArmed` is explicitly set, preventing duplicate prompts
86
- - Changed path normalization in contract validation to use `path.posix.normalize()` for consistent path handling
87
96
  - Changed autoresearch initialization to collect and validate benchmark command, metric definition, scope paths, off-limits list, and constraints before `init_experiment`
88
97
  - Changed `init_experiment` to require exact benchmark command, metric definition, scope, off-limits, and constraints matching collected contract
89
98
  - Changed `log_experiment` to record run number, benchmark command, scope paths, off-limits list, constraints, and segment fingerprint with each result
@@ -133,15 +142,16 @@
133
142
 
134
143
  ### Fixed
135
144
 
136
- - Fixed boundary duplication warnings to always display when replacement lines match the next surviving line, even when auto-correction is disabled
137
- - Fixed secondary metrics validation to properly reject missing configured metrics and new metrics without force flag
138
- - Fixed ASI data cloning to prevent prototype pollution attacks by filtering reserved property names
139
145
  - Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions
140
146
  - Fixed dashboard overlay to display when running experiment even with zero completed results
141
147
  - Fixed tab character rendering in dashboard command display and tool output summaries
142
148
  - Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
143
149
  - Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
144
150
 
151
+
152
+ ### Fixed
153
+
154
+ - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505))
145
155
  ## [13.14.0] - 2026-03-20
146
156
 
147
157
  ### Added
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.2",
4
+ "version": "13.16.0",
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.2",
46
- "@oh-my-pi/pi-agent-core": "13.15.2",
47
- "@oh-my-pi/pi-ai": "13.15.2",
48
- "@oh-my-pi/pi-natives": "13.15.2",
49
- "@oh-my-pi/pi-tui": "13.15.2",
50
- "@oh-my-pi/pi-utils": "13.15.2",
45
+ "@oh-my-pi/omp-stats": "13.16.0",
46
+ "@oh-my-pi/pi-agent-core": "13.16.0",
47
+ "@oh-my-pi/pi-ai": "13.16.0",
48
+ "@oh-my-pi/pi-natives": "13.16.0",
49
+ "@oh-my-pi/pi-tui": "13.16.0",
50
+ "@oh-my-pi/pi-utils": "13.16.0",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@xterm/headless": "^6.0",
53
53
  "ajv": "^8.18",
@@ -25,6 +25,7 @@ interface AppKeybindings {
25
25
  "app.model.cycleForward": true;
26
26
  "app.model.cycleBackward": true;
27
27
  "app.model.select": true;
28
+ "app.model.selectTemporary": true;
28
29
  "app.tools.expand": true;
29
30
  "app.editor.external": true;
30
31
  "app.message.followUp": true;
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
95
96
  defaultKeys: "ctrl+l",
96
97
  description: "Select model",
97
98
  },
99
+ "app.model.selectTemporary": {
100
+ defaultKeys: "alt+p",
101
+ description: "Select temporary model for current session",
102
+ },
98
103
  "app.tools.expand": {
99
104
  defaultKeys: "ctrl+o",
100
105
  description: "Expand tools",
@@ -194,6 +199,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
194
199
  cycleModelForward: "app.model.cycleForward",
195
200
  cycleModelBackward: "app.model.cycleBackward",
196
201
  selectModel: "app.model.select",
202
+ selectModelTemporary: "app.model.selectTemporary",
197
203
  togglePlanMode: "app.plan.toggle",
198
204
  historySearch: "app.history.search",
199
205
  expandTools: "app.tools.expand",
@@ -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;
@@ -161,7 +161,12 @@ export interface ExtensionUIContext {
161
161
  getEditorText(): string;
162
162
 
163
163
  /** Show a multi-line editor for text editing. */
164
- editor(title: string, prefill?: string): Promise<string | undefined>;
164
+ editor(
165
+ title: string,
166
+ prefill?: string,
167
+ dialogOptions?: ExtensionUIDialogOptions,
168
+ editorOptions?: { promptStyle?: boolean },
169
+ ): Promise<string | undefined>;
165
170
 
166
171
  /** Set a custom editor component via factory function, or undefined to restore the default editor. */
167
172
  setEditorComponent(
@@ -120,7 +120,7 @@ export interface HookUIContext {
120
120
  * @param prefill - Optional initial text
121
121
  * @returns Edited text, or undefined if cancelled (Escape)
122
122
  */
123
- editor(title: string, prefill?: string): Promise<string | undefined>;
123
+ editor(title: string, prefill?: string, options?: { signal?: AbortSignal }): Promise<string | undefined>;
124
124
 
125
125
  /**
126
126
  * Get the current theme for styling text with ANSI codes.