From 5b3275c7ae9664e255a05768e4000b3de2feb86a Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 23 Jul 2026 20:43:36 +0200 Subject: [PATCH] feat(coding-agent): introduced ordered provider priority lists for search and images - Replaced single-provider preferences with ordered priority lists for web search and image generation. - Added a `MultiSelectSubmenu` component supporting toggle and reordering interactions in settings. - Implemented migration logic to convert legacy single-provider preferences into ordered priority lists. - Updated setup wizard scenes, image generation fallback logic, and search provider chains to use priority lists. --- docs/settings.md | 8 +- docs/tools/web_search.md | 9 +- packages/coding-agent/CHANGELOG.md | 5 + .../src/config/provider-globals.ts | 16 +- .../src/config/settings-schema.ts | 76 +++----- packages/coding-agent/src/config/settings.ts | 41 +++++ .../src/modes/components/settings-defs.ts | 17 +- .../src/modes/components/settings-selector.ts | 170 +++++++++++++++++- .../modes/controllers/selector-controller.ts | 16 +- .../modes/setup-wizard/scenes/web-search.ts | 26 ++- .../prompts/system/subagent-async-pending.md | 2 +- packages/coding-agent/src/sdk.ts | 28 +-- packages/coding-agent/src/tools/image-gen.ts | 48 +++-- .../coding-agent/src/tools/image-providers.ts | 50 ++++++ packages/coding-agent/src/web/search/index.ts | 13 +- .../coding-agent/src/web/search/provider.ts | 38 ++-- packages/coding-agent/src/web/search/types.ts | 3 + .../test/config/provider-globals.test.ts | 13 +- .../components/settings-multiselect.test.ts | 131 ++++++++++++++ .../test/sdk-tool-activation.test.ts | 2 +- .../test/settings-manager.test.ts | 42 +++++ .../coding-agent/test/setup-wizard.test.ts | 26 +-- .../coding-agent/test/tools/image-gen.test.ts | 16 +- .../web/search/cli-provider-settings.test.ts | 26 ++- .../test/web/search/provider-chain.test.ts | 21 ++- 25 files changed, 613 insertions(+), 230 deletions(-) create mode 100644 packages/coding-agent/src/tools/image-providers.ts create mode 100644 packages/coding-agent/test/modes/components/settings-multiselect.test.ts diff --git a/docs/settings.md b/docs/settings.md index 93d67a1e6..b451c4985 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -647,9 +647,8 @@ For a custom status line, set `statusLine.preset: custom` and configure `statusL ```yaml providers: - webSearch: auto webSearchOrder: [perplexity, exa, gemini] - image: auto + imageOrder: [openai, xai] fetch: auto webSearchGeminiModel: gemini-2.5-flash tinyModel: online @@ -675,10 +674,9 @@ searxng: | Key | Type | Default | Values / notes | |---|---|---|---| -| `providers.webSearch` | enum | `auto` | `auto` plus the configured search providers (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`). | -| `providers.webSearchOrder` | array | `[]` | Provider IDs to prioritize after the preferred provider. Duplicates and unknown IDs are ignored; unlisted providers retain their built-in relative order afterward. | +| `providers.webSearchOrder` | array | `[]` | Provider IDs in priority order for `web_search` (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, …). Duplicates and unknown IDs are ignored; unlisted providers retain their built-in relative order afterward. Empty = built-in order. Replaces the removed `providers.webSearch` enum (a legacy value migrates to the head of this list). | | `providers.webSearchGeminiModel` | string | _(unset)_ | Gemini model ID for Google Search grounding when `web_search` uses Gemini; defaults to `gemini-2.5-flash`, overridden by `GEMINI_SEARCH_MODEL`. | -| `providers.image` | enum | `auto` | `auto`, `openai`, `antigravity`, `xai`, `gemini`, `openrouter`. | +| `providers.imageOrder` | array | `[]` | Image-generation provider IDs in priority order (`openai`, `openai-codex`, `antigravity`, `xai`, `gemini`, `openrouter`). Unlisted providers follow the active session provider and the built-in order. Replaces the removed `providers.image` enum (a legacy value migrates to the head of this list). | | `providers.fetch` | enum | `auto` | `auto`, `native`, `trafilatura`, `lynx`, `parallel`, `jina`. | | `providers.tinyModel` | enum | `online` | `online` or a local model (`lfm2-350m`, `qwen3-0.6b`, `gemma-270m`, `qwen2.5-0.5b`, `lfm2-700m`). | | `providers.tinyModelDevice` | enum | `default` | ONNX execution provider for local tiny models. Overridden by `PI_TINY_DEVICE`. | diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index 4172d40bc..f54eaa1bb 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -101,10 +101,9 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec ## Modes / Variants - **Provider selection** - - **Forced provider**: internal callers may pass `provider`; a non-`auto` value is the only attempted provider, while `auto` bypasses the configured preference and uses the ordered fallback chain. This field is not in the model-facing schema. - - **Preferred provider**: `setPreferredSearchProvider()` sets a module-global first choice used by `resolveProviderCandidates()`. Failure continues through the configured fallback order. `packages/coding-agent/src/sdk.ts` and `packages/coding-agent/src/modes/controllers/selector-controller.ts` wire this from settings. - - **Configured order**: `setSearchProviderOrder()` prioritizes the valid, first-occurrence provider IDs in `providers.webSearchOrder`; providers omitted from the setting follow in their built-in relative order. `providers.webSearch` remains the first-choice override. - - **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderCandidates()` must skip, including as fallbacks. Wired from the `providers.webSearchExclude` setting (`providers.webSearch` drives the preferred provider) in `packages/coding-agent/src/sdk.ts`, `packages/coding-agent/src/modes/interactive-mode.ts`, and `packages/coding-agent/src/modes/controllers/selector-controller.ts`. + - **Forced provider**: internal callers may pass `provider`; a non-`auto` value is the only attempted provider, while `auto` (or omitting it) walks the configured chain. This field is not in the model-facing schema. + - **Configured order**: `setSearchProviderOrder()` prioritizes the valid, first-occurrence provider IDs in `providers.webSearchOrder`; providers omitted from the setting follow in their built-in relative order. Listed providers are explicit selections — they resolve through `isExplicitlyAvailable()`, so e.g. a hand-listed Perplexity may fall back to anonymous search. Wired from settings in `packages/coding-agent/src/config/provider-globals.ts` (SDK startup, cwd reloads, live settings changes). + - **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderCandidates()` must skip, including as fallbacks. Wired from the `providers.webSearchExclude` setting via the same `provider-globals.ts` paths. - **Default auto chain order** (25 providers): `perplexity`, `gemini`, `anthropic`, `codex`, `xai`, `zai`, `exa`, `tinyfish`, `jina`, `kagi`, `tavily`, `firecrawl`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, `duckduckgo`, `bing`, `yahoo`, `startpage`, `google`, `ecosia`, `mojeek`, `public` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). `public` is explicit-only: its `isAvailable()` returns `false` so the auto chain never fans out implicitly. - **Provider adapters** - **Perplexity** — `packages/coding-agent/src/web/search/providers/perplexity.ts` @@ -148,7 +147,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - `limit` and `num_search_results` are collapsed together before dispatch. - Output may include parsed free-text `answer`, `sources`, `requestId`. - **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts` - - Availability: env or `agent.db` credential for `exa` admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (`providers.webSearch: exa`) reaches Exa even without a credential and falls back to public MCP. + - Availability: env or `agent.db` credential for `exa` admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (listing `exa` in `providers.webSearchOrder`, or a forced `provider: exa`) reaches Exa even without a credential and falls back to public MCP. - Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`. - `limit` and `num_search_results` are collapsed together before dispatch. - Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5100e013e..1317f2044 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Replaced the `providers.webSearch` and `providers.image` single-preference enums with the `providers.webSearchOrder` and `providers.imageOrder` priority lists. Existing config values migrate automatically: a concrete legacy choice becomes the head of the new list with the remaining providers appended in their built-in order, and `auto` simply resets to the default chain. + ### Added - Added `error.notify` so failed model turns can emit distinct terminal/desktop notifications without changing completion notifications ([#2691](https://github.com/can1357/oh-my-pi/issues/2691)). @@ -12,6 +16,7 @@ - Added `friendlyName` support for hidden secrets so model-visible placeholders can carry sanitized semantic labels, content-derived hashes, and case hints while preserving exact deobfuscation ([#2465](https://github.com/can1357/oh-my-pi/issues/2465)). - Made the statusline `git` segment jj-aware: in a Jujutsu repo it shows the nearest bookmark (falling back to the short change-id) instead of git's `detached` label or nothing, and working-copy change counts come from jj where there is no `.git` to read ([#3582](https://github.com/can1357/oh-my-pi/issues/3582)) - Added `block`/`unblock` todo operations and a `blocked` status for tasks waiting on external input; blocked tasks stay visible in the todo HUD and summary but are excluded from the incomplete-todo stop reminder, and an optional blocker note records what the task is waiting for. +- Added a toggle-list editor in `/settings` for array-of-enum settings: `providers.webSearchOrder` and `providers.imageOrder` (ordered — Enter/Space toggles, ←/→ nudges, 1-9 splices the hovered provider into that position) and `providers.webSearchExclude` now appear under Providers → Services instead of being config-file only. ### Changed diff --git a/packages/coding-agent/src/config/provider-globals.ts b/packages/coding-agent/src/config/provider-globals.ts index 3d2bfe01c..a5c8bbe94 100644 --- a/packages/coding-agent/src/config/provider-globals.ts +++ b/packages/coding-agent/src/config/provider-globals.ts @@ -1,11 +1,10 @@ -import * as imageGen from "../tools/image-gen"; +import { setImageProviderOrder } from "../tools/image-gen"; import * as webSearch from "../web/search"; interface ProviderGlobalSettings { get(path: "providers.webSearchOrder"): unknown; get(path: "providers.webSearchExclude"): unknown; - get(path: "providers.webSearch"): unknown; - get(path: "providers.image"): unknown; + get(path: "providers.imageOrder"): unknown; } export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void { @@ -19,13 +18,8 @@ export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSetting webSearch.setSearchProviderOrder(orderedWebSearchProviders.filter(webSearch.isSearchProviderId)); } - const webSearchProvider = settings.get("providers.webSearch"); - if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) { - webSearch.setPreferredSearchProvider(webSearchProvider); - } - - const imageProvider = settings.get("providers.image"); - if (imageGen.isImageProviderPreference(imageProvider)) { - imageGen.setPreferredImageProvider(imageProvider); + const orderedImageProviders = settings.get("providers.imageOrder"); + if (Array.isArray(orderedImageProviders)) { + setImageProviderOrder(orderedImageProviders.filter((entry): entry is string => typeof entry === "string")); } } diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index f3c89522b..168f6603d 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -26,6 +26,7 @@ import { TINY_TITLE_MODEL_OPTIONS, TINY_TITLE_MODEL_VALUES, } from "../tiny/models"; +import { IMAGE_PROVIDER_CHOICES, type ImageProvider } from "../tools/image-providers"; import { DEFAULT_TTS_LOCAL_MODEL_KEY, DEFAULT_TTS_VOICE, @@ -35,7 +36,7 @@ import { TTS_LOCAL_VOICE_VALUES, } from "../tts/models"; import { EDIT_MODES } from "../utils/edit-mode"; -import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES, type SearchProviderId } from "../web/search/types"; +import { SEARCH_PROVIDER_CHOICES, type SearchProviderId } from "../web/search/types"; import { SERVICE_TIER_ANTHROPIC_OPTIONS, SERVICE_TIER_ANTHROPIC_VALUES, @@ -218,10 +219,18 @@ interface UiString extends UiBase { options?: ReadonlyArray | "runtime"; } +interface UiArray extends UiBase { + /** Membership choices. Without options, an array setting has no UI representation (config-file only). */ + options?: ReadonlyArray; + /** Selection order is meaningful; the editor renders positions and supports reordering. */ + ordered?: boolean; +} + /** Wide ui shape exposed to consumers that walk the schema generically. */ export type AnyUiMetadata = UiBase & { options?: ReadonlyArray | "runtime"; secret?: boolean; + ordered?: boolean; }; interface BooleanDef { @@ -252,7 +261,7 @@ interface EnumDef { interface ArrayDef { type: "array"; default: T[]; - ui?: UiBase; + ui?: UiArray; } interface RecordDef { @@ -4555,18 +4564,6 @@ export const SETTINGS_SCHEMA = { "Maximum concurrent Ollama Cloud subagent runs per process; 0 disables the provider-specific limit", }, }, - "providers.webSearch": { - type: "enum", - values: SEARCH_PROVIDER_PREFERENCES, - default: "auto", - ui: { - tab: "providers", - group: "Services", - label: "Web Search Provider", - description: "Preferred provider for the web_search tool", - options: SEARCH_PROVIDER_OPTIONS, - }, - }, "providers.webSearchOrder": { type: "array", default: [] as SearchProviderId[], @@ -4574,7 +4571,10 @@ export const SETTINGS_SCHEMA = { tab: "providers", group: "Services", label: "Web Search Provider Order", - description: "Prioritized providers; unlisted providers retain their default order afterward", + description: + "Prioritized providers for the web_search tool; unlisted providers retain their default order afterward", + options: SEARCH_PROVIDER_CHOICES, + ordered: true, }, }, "providers.webSearchExclude": { @@ -4585,6 +4585,7 @@ export const SETTINGS_SCHEMA = { group: "Services", label: "Excluded Web Search Providers", description: "Providers that web_search should never use, even as fallbacks", + options: SEARCH_PROVIDER_CHOICES, }, }, "providers.webSearchGeminiModel": { @@ -4625,46 +4626,17 @@ export const SETTINGS_SCHEMA = { ], }, }, - "providers.image": { - type: "enum", - values: ["auto", "openai", "openai-codex", "antigravity", "xai", "gemini", "openrouter"] as const, - default: "auto", + "providers.imageOrder": { + type: "array", + default: [] as ImageProvider[], ui: { tab: "providers", group: "Services", - label: "Image Provider", - description: "Preferred provider for image generation", - options: [ - { - value: "auto", - label: "Auto", - description: - "Priority: per-request provider > configured provider > active session provider > GPT model image tool > Codex subscription > Antigravity > xAI > OpenRouter > Gemini", - }, - { - value: "openai", - label: "OpenAI", - description: - "OPENAI_API_KEY (gpt-image-2) or active GPT model; falls back to a connected Codex subscription", - }, - { - value: "openai-codex", - label: "OpenAI Codex (ChatGPT)", - description: "Uses a connected Codex / ChatGPT subscription — no OPENAI_API_KEY needed", - }, - { - value: "antigravity", - label: "Antigravity", - description: "Requires google-antigravity OAuth", - }, - { - value: "xai", - label: "xAI Grok Imagine", - description: "Requires xAI Grok OAuth or XAI_API_KEY", - }, - { value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" }, - { value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" }, - ], + label: "Image Provider Order", + description: + "Prioritized providers for image generation; unlisted providers follow the active session provider and the built-in order", + options: IMAGE_PROVIDER_CHOICES, + ordered: true, }, }, "providers.fireworksTier": { diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index a81d9299f..399c74465 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -34,7 +34,9 @@ import type { ModelRole } from "../config/model-roles"; import { loadCapability } from "../discovery"; import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme"; import { AgentStorage } from "../session/agent-storage"; +import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers"; import { type EditMode, normalizeEditMode } from "../utils/edit-mode"; +import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types"; import { withFileLock } from "./file-lock"; import { type BashInterceptorRule, @@ -1596,6 +1598,45 @@ export class Settings { delete raw["mcp.discoveryMode"]; delete raw["mcp.discoveryDefaultServers"]; + // providers.webSearch / providers.image (single preferred provider) → + // providers.webSearchOrder / providers.imageOrder (priority lists). A + // concrete legacy choice becomes the head of the new list with every + // remaining provider appended in its built-in order, so the old + // preference stays #1 and the fallback chain is written out explicitly. + // "auto" (or an unknown id) just drops the key — the default chain. + const providerPrefsObj = raw.providers as Record | undefined; + const migrateProviderPreference = ( + legacyKey: string, + orderKey: string, + expand: (value: string) => string[] | undefined, + ): void => { + const flatLegacyKey = `providers.${legacyKey}`; + const legacy = providerPrefsObj?.[legacyKey] ?? raw[flatLegacyKey]; + if (legacy === undefined) return; + const existingOrder = providerPrefsObj?.[orderKey] ?? raw[`providers.${orderKey}`]; + const orderAlreadySet = Array.isArray(existingOrder) && existingOrder.length > 0; + if (!orderAlreadySet && typeof legacy === "string") { + const expanded = expand(legacy); + if (expanded) { + const root = providerPrefsObj ?? {}; + root[orderKey] = expanded; + raw.providers = root; + } + } + if (providerPrefsObj) delete providerPrefsObj[legacyKey]; + delete raw[flatLegacyKey]; + }; + migrateProviderPreference("webSearch", "webSearchOrder", value => + value !== "auto" && isSearchProviderId(value) + ? [value, ...SEARCH_PROVIDER_ORDER.filter(id => id !== value)] + : undefined, + ); + migrateProviderPreference("image", "imageOrder", value => + value !== "auto" && isImageProviderId(value) + ? [value, ...AUTO_IMAGE_PROVIDER_ORDER.filter(id => id !== value)] + : undefined, + ); + return raw; } diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 766369b78..9ae777271 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -73,12 +73,20 @@ export interface ProviderLimitsSettingDef extends BaseSettingDef { type: "providerLimits"; } +/** Array-of-enum setting edited as a toggle list; `ordered` lists render positions and support reordering. */ +export interface MultiSelectSettingDef extends BaseSettingDef { + type: "multiselect"; + options: OptionList; + ordered: boolean; +} + export type SettingDef = | BooleanSettingDef | EnumSettingDef | SubmenuSettingDef | TextInputSettingDef - | ProviderLimitsSettingDef; + | ProviderLimitsSettingDef + | MultiSelectSettingDef; // ═══════════════════════════════════════════════════════════════════════════ // Condition Functions @@ -180,6 +188,13 @@ function pathToSettingDef(path: SettingPath): SettingDef | null { return { ...base, type: "text", secret: ui.secret === true }; } + if (schemaType === "array") { + // Arrays without declared options stay config-file only (free-form lists + // like extension paths have no finite choice set to toggle). + if (!options || options === "runtime") return null; + return { ...base, type: "multiselect", options, ordered: ui.ordered === true }; + } + if (schemaType === "record") { return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index ecc2834e4..4527c70a2 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -221,6 +221,131 @@ class SelectSubmenu extends Container { } } +/** + * Submenu for array-of-enum settings: every option is a toggle row. Enter or + * Space flips membership; ordered lists render 1-based positions and reorder + * the highlighted member with ←/→. Changes apply live; Esc goes back. + */ +class MultiSelectSubmenu extends Container { + #selectList!: SelectList; + #value: string[]; + #cursor = 0; + #selectListLineOffset = 0; + + constructor( + private readonly title: string, + private readonly description: string, + private readonly options: ReadonlyArray, + initial: readonly string[], + private readonly ordered: boolean, + private readonly onApply: (value: string[]) => void, + private readonly onClose: () => void, + ) { + super(); + // Drop stale ids (renamed/removed providers) so positions stay contiguous. + this.#value = initial.filter(id => options.some(option => option.value === id)); + this.#rebuild(); + } + + #rebuild(): void { + this.clear(); + this.addChild(new Text(theme.bold(theme.fg("accent", this.title)), 0, 0)); + if (this.description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", this.description), 0, 0)); + } + this.addChild(new Spacer(1)); + + const items = this.options.map((option): SelectItem => { + const position = this.#value.indexOf(option.value); + const mark = + position === -1 + ? theme.fg("dim", this.ordered ? " · " : " ○ ") + : this.ordered + ? theme.fg("accent", `${String(position + 1).padStart(2)}.`) + : theme.fg("accent", " ● "); + return { value: option.value, label: `${mark} ${option.label}`, description: option.description }; + }); + this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + this.#selectList.setSelectedIndex(this.#cursor); + this.#selectList.onSelect = item => this.#toggle(item.value); + this.#selectList.onSelectionChange = item => { + this.#cursor = this.options.findIndex(option => option.value === item.value); + }; + this.#selectList.onCancel = this.onClose; + this.addChild(this.#selectList); + + this.addChild(new Spacer(1)); + const hint = this.ordered + ? " Enter/Space to toggle · ←/→ move · 1-9 place at position · Esc to go back" + : " Enter/Space to toggle · Esc to go back"; + this.addChild(new Text(theme.fg("dim", hint), 0, 0)); + } + + #apply(next: string[]): void { + this.#value = next; + this.onApply([...next]); + this.#rebuild(); + } + + #toggle(id: string): void { + const next = this.#value.includes(id) ? this.#value.filter(v => v !== id) : [...this.#value, id]; + this.#apply(next); + } + + #move(id: string, delta: -1 | 1): void { + const from = this.#value.indexOf(id); + if (from === -1) return; + const to = from + delta; + if (to < 0 || to >= this.#value.length) return; + const next = [...this.#value]; + next[from] = next[to]!; + next[to] = id; + this.#apply(next); + } + + /** Splice the option into the 1-based `position` of the selection (adding it if unselected). */ + #placeAt(id: string, position: number): void { + const next = this.#value.filter(v => v !== id); + next.splice(Math.min(position - 1, next.length), 0, id); + this.#apply(next); + } + + /** Concatenate children, recording the select list's line offset for mouse routing. */ + override render(width: number): readonly string[] { + const lines: string[] = []; + for (const child of this.children) { + const childLines = child.render(Math.max(1, width)); + if (child === this.#selectList) { + this.#selectListLineOffset = lines.length; + } + lines.push(...childLines); + } + return lines; + } + + routeMouse(event: SgrMouseEvent, line: number, _col: number): void { + routeSelectListMouse(this.#selectList, event, line - this.#selectListLineOffset); + } + + handleInput(data: string): void { + const current = this.options[this.#cursor]?.value; + if (data === " " && current !== undefined) { + this.#toggle(current); + return; + } + if (this.ordered && current !== undefined && (data === "\x1b[D" || data === "\x1b[C")) { + this.#move(current, data === "\x1b[D" ? -1 : 1); + return; + } + if (this.ordered && current !== undefined && data.length === 1 && data >= "1" && data <= "9") { + this.#placeAt(current, Number(data)); + return; + } + this.#selectList.handleInput(data); + } +} + class ProviderLimitsSubmenu extends Container { #selectList: SelectList | undefined; @@ -854,6 +979,16 @@ export class SettingsSelectorComponent implements Component { submenu: (_cv, done) => this.#createProviderLimitsInput(done), changed, }; + + case "multiselect": + return { + id: def.path, + label: def.label, + description: def.description, + currentValue: this.#formatMultiSelectValue(def, currentValue), + submenu: (_cv, done) => this.#createMultiSelect(def, done), + changed, + }; } } @@ -865,7 +1000,14 @@ export class SettingsSelectorComponent implements Component { } #isChanged(def: SettingDef, currentValue: unknown): boolean { - return !Object.is(currentValue, getDefault(def.path)); + const defaultValue: unknown = getDefault(def.path); + if (Array.isArray(currentValue) && Array.isArray(defaultValue)) { + return ( + currentValue.length !== defaultValue.length || + currentValue.some((entry, index) => entry !== defaultValue[index]) + ); + } + return !Object.is(currentValue, defaultValue); } #getSubmenuCurrentValue(path: SettingPath, value: unknown): string { @@ -1026,6 +1168,32 @@ export class SettingsSelectorComponent implements Component { return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", "); } + #createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container { + const current: unknown = settings.get(def.path); + const initial = Array.isArray(current) + ? current.filter((entry): entry is string => typeof entry === "string") + : []; + return new MultiSelectSubmenu( + def.label, + def.description, + def.options, + initial, + def.ordered, + value => { + settings.set(def.path, value as never); + this.callbacks.onChange(def.path, value); + }, + () => done(this.#formatMultiSelectValue(def, settings.get(def.path))), + ); + } + + #formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string { + const ids = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; + if (ids.length === 0) return def.ordered ? "default" : "none"; + const labels = ids.map(id => def.options.find(option => option.value === id)?.label ?? id); + return def.ordered ? labels.join(" → ") : labels.join(", "); + } + #formatTextInputValue(def: SettingDef & { type: "text" }, value: unknown): string { if (def.secret) return value ? "••••••••" : ""; return this.#formatTextInputEditValue(def.path, value); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index f323b8767..cafc35267 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -57,12 +57,9 @@ import { parseConfiguredThinkingLevel, } from "../../thinking"; import { - isImageProviderPreference, isSearchProviderId, - isSearchProviderPreference, setExcludedSearchProviders, - setPreferredImageProvider, - setPreferredSearchProvider, + setImageProviderOrder, setSearchProviderOrder, type ToolSession, } from "../../tools"; @@ -610,11 +607,6 @@ export class SelectorController { } // Provider settings - update runtime preferences - case "providers.webSearch": - if (typeof value === "string" && isSearchProviderPreference(value)) { - setPreferredSearchProvider(value); - } - break; case "providers.webSearchOrder": if (Array.isArray(value)) { setSearchProviderOrder(value.filter(isSearchProviderId)); @@ -625,9 +617,9 @@ export class SelectorController { setExcludedSearchProviders(value.filter(isSearchProviderId)); } break; - case "providers.image": - if (isImageProviderPreference(value)) { - setPreferredImageProvider(value); + case "providers.imageOrder": + if (Array.isArray(value)) { + setImageProviderOrder(value.filter((entry): entry is string => typeof entry === "string")); } break; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts index 4e75ea9af..6279aceaf 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts @@ -5,16 +5,20 @@ import { type SgrMouseEvent, truncateToWidth, } from "@oh-my-pi/pi-tui"; -import { SETTINGS_SCHEMA } from "../../../config/settings-schema"; -import { getSearchProvider, setPreferredSearchProvider } from "../../../web/search/provider"; -import { isSearchProviderPreference, type SearchProviderId } from "../../../web/search/types"; +import { getSearchProvider, setSearchProviderOrder } from "../../../web/search/provider"; +import { + isSearchProviderId, + SEARCH_PROVIDER_OPTIONS, + SEARCH_PROVIDER_ORDER, + type SearchProviderId, +} from "../../../web/search/types"; import { getSelectListTheme, theme } from "../../theme/theme"; import type { SetupSceneHost, SetupTab } from "./types"; const MAX_VISIBLE = 8; -/** Reuse the settings schema as the single source of truth for labels/descriptions. */ -const WEB_SEARCH_ITEMS: readonly SelectItem[] = SETTINGS_SCHEMA["providers.webSearch"].ui.options.map(option => ({ +/** Reuse the shared provider options as the single source of truth for labels/descriptions. */ +const WEB_SEARCH_ITEMS: readonly SelectItem[] = SEARCH_PROVIDER_OPTIONS.map(option => ({ value: option.value, label: option.label, description: option.description, @@ -42,7 +46,8 @@ export class WebSearchTab implements SetupTab { constructor(private readonly host: SetupSceneHost) { this.#list = new SelectList(WEB_SEARCH_ITEMS, MAX_VISIBLE, getSelectListTheme()); - const current = host.ctx.settings.get("providers.webSearch"); + const order = host.ctx.settings.get("providers.webSearchOrder"); + const current = Array.isArray(order) && typeof order[0] === "string" ? order[0] : "auto"; const index = WEB_SEARCH_ITEMS.findIndex(item => item.value === current); if (index >= 0) this.#list.setSelectedIndex(index); this.#list.onSelectionChange = item => this.#onHighlight(item.value); @@ -119,9 +124,12 @@ export class WebSearchTab implements SetupTab { } #apply(value: string): void { - if (!isSearchProviderPreference(value)) return; - this.host.ctx.settings.set("providers.webSearch", value); - setPreferredSearchProvider(value); + if (value !== "auto" && !isSearchProviderId(value)) return; + // The wizard picks one favorite; persist it as the head of the priority + // list with the remaining providers in their built-in order (auto = reset). + const order = value === "auto" ? [] : [value, ...SEARCH_PROVIDER_ORDER.filter(id => id !== value)]; + this.host.ctx.settings.set("providers.webSearchOrder", order); + setSearchProviderOrder(order); const label = WEB_SEARCH_ITEMS.find(item => item.value === value)?.label ?? value; this.#status = [theme.fg("success", `${theme.status.success} Web search set to ${label}`)]; if (value !== "auto" && this.#availability.get(value as SearchProviderId) === false) { diff --git a/packages/coding-agent/src/prompts/system/subagent-async-pending.md b/packages/coding-agent/src/prompts/system/subagent-async-pending.md index daee84c25..e39e3e64b 100644 --- a/packages/coding-agent/src/prompts/system/subagent-async-pending.md +++ b/packages/coding-agent/src/prompts/system/subagent-async-pending.md @@ -2,5 +2,5 @@ Your yield was recorded, but {{count}} background job{{#if multiple}}s{{/if}} yo This run completes only after these jobs settle AND you submit a fresh `yield` that accounts for their results. Job results arrive as follow-up messages; a result that arrives after your yield supersedes it — your current yield will NOT be accepted as the final report. Decide now: - Need the results? Wait for them (`hub` op:"wait"), then submit a fresh `yield` that incorporates them. -- Job no longer needed? Cancel it (`hub` op:"cancel", ids:[...]) and re-yield. +- Job no longer needed? Cancel it (`hub` op:"cancel", ids:[…]) and re-yield. - Otherwise stand by; when each result arrives, submit a fresh `yield` (repeat your report unchanged if the result does not affect it). diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 183e46fe4..4fdc7ee7e 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -55,6 +55,7 @@ import { resolveModelRoleValue, } from "./config/model-resolver"; import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates"; +import { applyProviderGlobalsFromSettings } from "./config/provider-globals"; import { buildServiceTierByFamily } from "./config/service-tier"; import { Settings, type SkillsSettings } from "./config/settings"; import { CursorExecHandlers } from "./cursor"; @@ -175,16 +176,9 @@ import { GrepTool, getSearchTools, HIDDEN_TOOLS, - isImageProviderPreference, isMountableUnderXdev, - isSearchProviderId, - isSearchProviderPreference, type LspStartupServerInfo, ReadTool, - setExcludedSearchProviders, - setPreferredImageProvider, - setPreferredSearchProvider, - setSearchProviderOrder, type Tool, type ToolSession, WebSearchTool, @@ -1256,25 +1250,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} discoveredSkillsPromise?.catch(() => {}); // Initialize provider preferences from settings - const excludedWebSearchProviders = settings.get("providers.webSearchExclude"); - if (Array.isArray(excludedWebSearchProviders)) { - setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId)); - } - - const orderedWebSearchProviders = settings.get("providers.webSearchOrder"); - if (Array.isArray(orderedWebSearchProviders)) { - setSearchProviderOrder(orderedWebSearchProviders.filter(isSearchProviderId)); - } - - const webSearchProvider = settings.get("providers.webSearch"); - if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) { - setPreferredSearchProvider(webSearchProvider); - } - - const imageProvider = settings.get("providers.image"); - if (isImageProviderPreference(imageProvider)) { - setPreferredImageProvider(imageProvider); - } + applyProviderGlobalsFromSettings(settings); const sessionManager = options.sessionManager ?? diff --git a/packages/coding-agent/src/tools/image-gen.ts b/packages/coding-agent/src/tools/image-gen.ts index 4bde95cb7..d24c115e3 100644 --- a/packages/coding-agent/src/tools/image-gen.ts +++ b/packages/coding-agent/src/tools/image-gen.ts @@ -27,6 +27,7 @@ import { settings } from "../config/settings"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http"; import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" }; +import { AUTO_IMAGE_PROVIDER_ORDER, type ImageProvider, isImageProviderId } from "./image-providers"; import { resolveReadPath } from "./path-utils"; const DEFAULT_MODEL = "gemini-3-pro-image-preview"; @@ -44,7 +45,7 @@ const DEFAULT_ANTIGRAVITY_ENDPOINT_SANDBOX = "https://daily-cloudcode-pa.sandbox const IMAGE_SYSTEM_INSTRUCTION = "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request."; -export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai"; +export type { ImageProvider } from "./image-providers"; export type ImageProviderPreference = ImageProvider | "auto"; interface ImageApiKey { @@ -57,17 +58,8 @@ interface ImageApiKey { const COMMON_IMAGE_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const; const XAI_IMAGE_ASPECT_RATIOS = [...COMMON_IMAGE_ASPECT_RATIOS, "3:2", "2:3"] as const; const COMMON_IMAGE_ASPECT_RATIO_SET = new Set(COMMON_IMAGE_ASPECT_RATIOS); -const IMAGE_PROVIDER_CHOICES = [ - "auto", - "antigravity", - "gemini", - "openai", - "openai-codex", - "openrouter", - "xai", -] as const; -const IMAGE_PROVIDER_PREFERENCES = new Set(IMAGE_PROVIDER_CHOICES); -const AUTO_IMAGE_PROVIDER_ORDER = ["openai", "openai-codex", "antigravity", "xai", "openrouter", "gemini"] as const; +const IMAGE_PROVIDER_REQUEST_CHOICES = ["auto", ...AUTO_IMAGE_PROVIDER_ORDER] as const; +const IMAGE_PROVIDER_PREFERENCES = new Set(IMAGE_PROVIDER_REQUEST_CHOICES); const responseModalitySchema = type('"IMAGE" | "TEXT"'); @@ -81,8 +73,8 @@ const inputImageSchema = type({ }); const imageProviderSchema = type - .enumerated(...IMAGE_PROVIDER_CHOICES) - .describe("image provider for this request; overrides the providers.image setting (default: use the setting)"); + .enumerated(...IMAGE_PROVIDER_REQUEST_CHOICES) + .describe("image provider for this request; overrides the providers.imageOrder setting (default: use the setting)"); export const imageGenSchema = type({ subject: type("string").describe("main subject"), @@ -447,16 +439,16 @@ function extractOpenRouterImageUrls(message: OpenRouterMessage | undefined): str return urls; } -/** Preferred provider set via settings (default: auto) */ -let preferredImageProvider: ImageProviderPreference = "auto"; +/** Configured provider priority set via `providers.imageOrder` (default: none). */ +let configuredImageProviderOrder: readonly ImageProvider[] = []; export function isImageProviderPreference(value: unknown): value is ImageProviderPreference { return typeof value === "string" && IMAGE_PROVIDER_PREFERENCES.has(value); } -/** Set the preferred image provider from settings */ -export function setPreferredImageProvider(provider: ImageProviderPreference): void { - preferredImageProvider = provider; +/** Set the configured image-provider priority from settings; invalid IDs are dropped. */ +export function setImageProviderOrder(providers: readonly string[]): void { + configuredImageProviderOrder = providers.filter(isImageProviderId); } function assertImageAspectRatioSupported(provider: ImageProvider, aspectRatio: ImageGenParams["aspect_ratio"]): void { if (!aspectRatio || provider === "xai" || COMMON_IMAGE_ASPECT_RATIO_SET.has(aspectRatio)) { @@ -624,19 +616,19 @@ function activeImageProvider(model: Model | undefined): Exclude> { - const providers: Array> = []; - const added = new Set>(); - const add = (provider: Exclude | null): void => { +function imageProviderOrder(activeModel: Model | undefined, requested?: ImageProviderPreference): ImageProvider[] { + const providers: ImageProvider[] = []; + const added = new Set(); + const add = (provider: ImageProvider | null): void => { if (!provider || added.has(provider)) return; added.add(provider); providers.push(provider); }; - if (preference !== "auto") add(preference); + // Per-request provider wins, then the configured priority list, then the + // active session's provider, then the built-in auto order. + if (requested !== undefined && requested !== "auto") add(requested); + for (const provider of configuredImageProviderOrder) add(provider); add(activeImageProvider(activeModel)); for (const provider of AUTO_IMAGE_PROVIDER_ORDER) add(provider); return providers; @@ -1109,7 +1101,7 @@ export const imageGenTool: CustomTool { const sessionId = ctx.sessionManager.getSessionId(); - const providerOrder = imageProviderOrder(ctx.model, params.provider ?? preferredImageProvider); + const providerOrder = imageProviderOrder(ctx.model, params.provider); const cwd = ctx.sessionManager.getCwd(); const requestSignal = ptree.combineSignals(signal, IMAGE_TIMEOUT); const fetchImpl = ctx.fetch ?? fetch; diff --git a/packages/coding-agent/src/tools/image-providers.ts b/packages/coding-agent/src/tools/image-providers.ts new file mode 100644 index 000000000..aa9164114 --- /dev/null +++ b/packages/coding-agent/src/tools/image-providers.ts @@ -0,0 +1,50 @@ +/** + * Image Generation Providers + * + * Leaf module (no runtime deps) shared by the image_gen tool, the settings + * schema, and settings migrations — mirrors `web/search/types.ts` so the + * provider list, auto order, and settings choices never drift apart. + */ + +/** Image generation backends, in settings/tool vocabulary. */ +export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai"; + +/** Auto-resolution fallback order when no configured entry or session provider matches. */ +export const AUTO_IMAGE_PROVIDER_ORDER: readonly ImageProvider[] = [ + "openai", + "openai-codex", + "antigravity", + "xai", + "openrouter", + "gemini", +]; + +/** Settings choices for `providers.imageOrder` (labels shared with the retired single-preference enum). */ +export const IMAGE_PROVIDER_CHOICES = [ + { + value: "openai", + label: "OpenAI", + description: "OPENAI_API_KEY (gpt-image-2) or active GPT model; falls back to a connected Codex subscription", + }, + { + value: "openai-codex", + label: "OpenAI Codex (ChatGPT)", + description: "Uses a connected Codex / ChatGPT subscription — no OPENAI_API_KEY needed", + }, + { + value: "antigravity", + label: "Antigravity", + description: "Requires google-antigravity OAuth", + }, + { + value: "xai", + label: "xAI Grok Imagine", + description: "Requires xAI Grok OAuth or XAI_API_KEY", + }, + { value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" }, + { value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" }, +] as const satisfies ReadonlyArray<{ value: ImageProvider; label: string; description: string }>; + +export function isImageProviderId(value: unknown): value is ImageProvider { + return typeof value === "string" && AUTO_IMAGE_PROVIDER_ORDER.includes(value as ImageProvider); +} diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index cba6b330c..83cf2c5dc 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -135,11 +135,9 @@ async function executeSearch( let candidates: SearchProviderCandidate[]; if (explicitProvider && explicitProvider !== "auto") { candidates = [{ id: explicitProvider, explicit: true }]; - } else if (explicitProvider === "auto") { - // Explicit `--provider auto` bypasses the configured preferred provider - // for this invocation; exclusions still apply. - candidates = resolveProviderCandidates("auto"); } else { + // `--provider auto` and the default both walk the configured chain; + // exclusions still apply. candidates = resolveProviderCandidates(); } @@ -350,11 +348,6 @@ export function getSearchTools(): CustomTool[] { return [webSearchCustomTool]; } -export { - getSearchProvider, - setExcludedSearchProviders, - setPreferredSearchProvider, - setSearchProviderOrder, -} from "./provider"; +export { getSearchProvider, setExcludedSearchProviders, setSearchProviderOrder } from "./provider"; export type { SearchProviderId as SearchProvider, SearchResponse } from "./types"; export { isSearchProviderId, isSearchProviderPreference } from "./types"; diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index e290c0ce4..173879825 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -189,23 +189,21 @@ export async function getSearchProvider(id: SearchProviderId): Promise(); /** * Prioritize configured providers while retaining every unlisted provider in - * its built-in relative order. Invalid IDs are ignored defensively. + * its built-in relative order. Invalid IDs are ignored defensively. Listed + * providers are treated as explicit selections: they resolve through + * `isExplicitlyAvailable`, so e.g. a hand-listed Perplexity may fall back to + * anonymous search exactly like the retired single-preference setting did. */ export function setSearchProviderOrder(providers: readonly SearchProviderId[]): void { const prioritized = new Set(providers.filter(id => SEARCH_PROVIDER_ORDER.includes(id))); + explicitProvIds = prioritized; orderedProvIds = prioritized.size === 0 ? SEARCH_PROVIDER_ORDER @@ -230,19 +228,21 @@ export interface SearchProviderCandidate { explicit: boolean; } -/** Return provider candidates in fallback order without loading their modules. */ -export function resolveProviderCandidates( - preferredProvider: SearchProviderId | "auto" = preferredProvId, -): SearchProviderCandidate[] { +/** + * Return provider candidates in fallback order without loading their modules. + * `forcedProvider` (a per-request `provider` argument) is terminal-first and + * bypasses exclusion; configured-order entries carry `explicit: true`. + */ +export function resolveProviderCandidates(forcedProvider?: SearchProviderId): SearchProviderCandidate[] { const candidates: SearchProviderCandidate[] = []; - if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) { - candidates.push({ id: preferredProvider, explicit: true }); + if (forcedProvider !== undefined && !isSearchProviderExcluded(forcedProvider)) { + candidates.push({ id: forcedProvider, explicit: true }); } for (const id of orderedProvIds) { - if (id === preferredProvider || isSearchProviderExcluded(id)) continue; - candidates.push({ id, explicit: false }); + if (id === forcedProvider || isSearchProviderExcluded(id)) continue; + candidates.push({ id, explicit: explicitProvIds.has(id) }); } return candidates; @@ -256,11 +256,11 @@ export function resolveProviderCandidates( */ export async function resolveProviderChain( authStorage: AuthStorage, - preferredProvider: SearchProviderId | "auto" = preferredProvId, + forcedProvider?: SearchProviderId, ): Promise { const providers: SearchProvider[] = []; - for (const candidate of resolveProviderCandidates(preferredProvider)) { + for (const candidate of resolveProviderCandidates(forcedProvider)) { const provider = await getSearchProvider(candidate.id); const available = candidate.explicit ? await provider.isExplicitlyAvailable(authStorage) diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index de6ae6b93..efa3ce5ed 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -96,6 +96,9 @@ export const SEARCH_PROVIDER_ORDER: readonly SearchProviderId[] = SEARCH_PROVIDE option.value === "auto" ? [] : [option.value], ); +/** Concrete provider choices (no `auto` sentinel) — for list-valued settings like order/exclude. */ +export const SEARCH_PROVIDER_CHOICES = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== "auto"); + export const SEARCH_PROVIDER_PREFERENCES = ["auto", ...SEARCH_PROVIDER_ORDER] as const; /** Display labels, derived from {@link SEARCH_PROVIDER_OPTIONS}. */ diff --git a/packages/coding-agent/test/config/provider-globals.test.ts b/packages/coding-agent/test/config/provider-globals.test.ts index d91370830..ec892044c 100644 --- a/packages/coding-agent/test/config/provider-globals.test.ts +++ b/packages/coding-agent/test/config/provider-globals.test.ts @@ -11,18 +11,14 @@ describe("applyProviderGlobalsFromSettings", () => { it("reapplies valid web and image provider globals from cwd-scoped settings", () => { const excludeSpy = vi.spyOn(webSearch, "setExcludedSearchProviders").mockImplementation(() => {}); const orderSpy = vi.spyOn(webSearch, "setSearchProviderOrder").mockImplementation(() => {}); - const webSpy = vi.spyOn(webSearch, "setPreferredSearchProvider").mockImplementation(() => {}); - const imageSpy = vi.spyOn(imageGen, "setPreferredImageProvider").mockImplementation(() => {}); + const imageOrderSpy = vi.spyOn(imageGen, "setImageProviderOrder").mockImplementation(() => {}); applyProviderGlobalsFromSettings({ - get( - path: "providers.webSearchOrder" | "providers.webSearchExclude" | "providers.webSearch" | "providers.image", - ): unknown { + get(path: "providers.webSearchOrder" | "providers.webSearchExclude" | "providers.imageOrder"): unknown { const values: Record = { "providers.webSearchOrder": ["perplexity", "not-a-provider", "exa"], "providers.webSearchExclude": ["exa", "not-a-provider", "gemini"], - "providers.webSearch": "perplexity", - "providers.image": "xai", + "providers.imageOrder": ["xai", 42, "gemini"], }; return values[path]; }, @@ -30,7 +26,6 @@ describe("applyProviderGlobalsFromSettings", () => { expect(orderSpy).toHaveBeenCalledWith(["perplexity", "exa"]); expect(excludeSpy).toHaveBeenCalledWith(["exa", "gemini"]); - expect(webSpy).toHaveBeenCalledWith("perplexity"); - expect(imageSpy).toHaveBeenCalledWith("xai"); + expect(imageOrderSpy).toHaveBeenCalledWith(["xai", "gemini"]); }); }); diff --git a/packages/coding-agent/test/modes/components/settings-multiselect.test.ts b/packages/coding-agent/test/modes/components/settings-multiselect.test.ts new file mode 100644 index 000000000..b8b543b07 --- /dev/null +++ b/packages/coding-agent/test/modes/components/settings-multiselect.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { SettingsSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/settings-selector"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { SEARCH_PROVIDER_CHOICES } from "@oh-my-pi/pi-coding-agent/web/search/types"; + +beforeAll(async () => { + await initTheme(); +}); + +let geometryStub: { restore(): void } | undefined; + +beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + geometryStub = stubStdoutGeometry(120); +}); + +afterEach(() => { + resetSettingsForTest(); + geometryStub?.restore(); + geometryStub = undefined; +}); + +function stubStdoutGeometry(cols: number): { restore(): void } { + const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows"); + const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + const rows = 40; + Object.defineProperty(process.stdout, "rows", { configurable: true, get: () => rows, set: () => {} }); + Object.defineProperty(process.stdout, "columns", { configurable: true, get: () => cols, set: () => {} }); + const restoreOne = (key: "rows" | "columns", desc: PropertyDescriptor | undefined) => { + if (desc) Object.defineProperty(process.stdout, key, desc); + }; + return { + restore() { + restoreOne("rows", rowsDesc); + restoreOne("columns", colsDesc); + }, + }; +} + +function createSelector(): SettingsSelectorComponent { + return new SettingsSelectorComponent( + { + availableThinkingLevels: [], + thinkingLevel: undefined, + availableThemes: ["dark"], + providers: [], + cwd: process.cwd(), + }, + { + onChange: () => {}, + onCancel: () => {}, + }, + ); +} + +const [firstChoice, secondChoice] = SEARCH_PROVIDER_CHOICES; + +describe("multiselect settings (array-of-enum)", () => { + it("edits providers.webSearchOrder via the ordered toggle list", () => { + const comp = createSelector(); + for (const ch of "web search provider order") comp.handleInput(ch); + const row = comp.render(120).join("\n"); + expect(row).toContain("Web Search Provider Order"); + expect(row).toContain("default"); + + // Open the editor; Space toggles the first provider, Enter the second. + comp.handleInput("\n"); + comp.handleInput(" "); + comp.handleInput("\x1b[B"); + comp.handleInput("\n"); + expect(settings.get("providers.webSearchOrder")).toEqual([firstChoice!.value, secondChoice!.value]); + + // ← promotes the highlighted member one slot earlier in priority. + comp.handleInput("\x1b[D"); + expect(settings.get("providers.webSearchOrder")).toEqual([secondChoice!.value, firstChoice!.value]); + + // Toggling a member off removes it and renumbers the rest. + comp.handleInput(" "); + expect(settings.get("providers.webSearchOrder")).toEqual([firstChoice!.value]); + + // Esc returns to the list; the row summary reflects the saved order. + comp.handleInput("\x1b"); + expect(comp.render(120).join("\n")).toContain(firstChoice!.label); + }); + + it("splices the hovered option into the pressed digit's position", () => { + const [a, b, c] = SEARCH_PROVIDER_CHOICES; + const comp = createSelector(); + for (const ch of "web search provider order") comp.handleInput(ch); + comp.handleInput("\n"); + + // Select rows 1 and 3 → [a, c]. + comp.handleInput(" "); + comp.handleInput("\x1b[B"); + comp.handleInput("\x1b[B"); + comp.handleInput(" "); + expect(settings.get("providers.webSearchOrder")).toEqual([a!.value, c!.value]); + + // Hover row 2 (unselected) and press "2" → spliced between them. + comp.handleInput("\x1b[A"); + comp.handleInput("2"); + expect(settings.get("providers.webSearchOrder")).toEqual([a!.value, b!.value, c!.value]); + + // Press "9" (past the end) → clamps to the tail. + comp.handleInput("9"); + expect(settings.get("providers.webSearchOrder")).toEqual([a!.value, c!.value, b!.value]); + + // Press "1" → promotes to the head. + comp.handleInput("1"); + expect(settings.get("providers.webSearchOrder")).toEqual([b!.value, a!.value, c!.value]); + }); + + it("edits providers.webSearchExclude as an unordered toggle set", () => { + const comp = createSelector(); + for (const ch of "excluded web search providers") comp.handleInput(ch); + expect(comp.render(120).join("\n")).toContain("none"); + + comp.handleInput("\n"); + comp.handleInput(" "); + expect(settings.get("providers.webSearchExclude")).toEqual([firstChoice!.value]); + + // Unordered lists ignore reorder keys. + comp.handleInput("\x1b[C"); + expect(settings.get("providers.webSearchExclude")).toEqual([firstChoice!.value]); + + comp.handleInput(" "); + expect(settings.get("providers.webSearchExclude")).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index 478e6084e..bc76d9325 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -370,7 +370,7 @@ describe("createAgentSession defaultInactive tool activation", () => { const normalDir = makeTempDir(); const configuredSettings = () => Settings.isolated({ - "providers.image": "openai", + "providers.imageOrder": ["openai"], "generate_image.enabled": true, "speechgen.enabled": true, "memory.backend": "hindsight", diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index a54b40cca..259d28893 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -16,6 +16,8 @@ import { Settings, } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; +import { AUTO_IMAGE_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/tools/image-providers"; +import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import * as fileLock from "../src/config/file-lock"; @@ -599,6 +601,46 @@ describe("Settings", () => { }); }); + describe("provider preference migration", () => { + it("expands a legacy providers.webSearch choice into the head of webSearchOrder", async () => { + await writeSettings({ providers: { webSearch: "exa" } }); + + const settings = await Settings.init({ cwd: projectDir, agentDir }); + + expect(settings.get("providers.webSearchOrder")).toEqual([ + "exa", + ...SEARCH_PROVIDER_ORDER.filter(id => id !== "exa"), + ]); + }); + + it("drops legacy providers.webSearch auto without seeding an order", async () => { + await writeSettings({ providers: { webSearch: "auto" } }); + + const settings = await Settings.init({ cwd: projectDir, agentDir }); + + expect(settings.get("providers.webSearchOrder")).toEqual([]); + }); + + it("keeps an explicit webSearchOrder over the legacy webSearch preference", async () => { + await writeSettings({ providers: { webSearch: "exa", webSearchOrder: ["gemini"] } }); + + const settings = await Settings.init({ cwd: projectDir, agentDir }); + + expect(settings.get("providers.webSearchOrder")).toEqual(["gemini"]); + }); + + it("expands a legacy providers.image choice into the head of imageOrder", async () => { + await writeSettings({ providers: { image: "xai" } }); + + const settings = await Settings.init({ cwd: projectDir, agentDir }); + + expect(settings.get("providers.imageOrder")).toEqual([ + "xai", + ...AUTO_IMAGE_PROVIDER_ORDER.filter(id => id !== "xai"), + ]); + }); + }); + describe("migrations", () => { it("maps removed atom edit mode settings to hashline", async () => { await writeSettings({ diff --git a/packages/coding-agent/test/setup-wizard.test.ts b/packages/coding-agent/test/setup-wizard.test.ts index 616df29c2..e1d7e81b6 100644 --- a/packages/coding-agent/test/setup-wizard.test.ts +++ b/packages/coding-agent/test/setup-wizard.test.ts @@ -3,7 +3,6 @@ import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { runOnboardingSetup } from "@oh-my-pi/pi-coding-agent/commands/setup"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { SETTINGS_SCHEMA } from "@oh-my-pi/pi-coding-agent/config/settings-schema"; import { ALL_SCENES, CURRENT_SETUP_VERSION, @@ -19,7 +18,7 @@ import { WebSearchTab } from "@oh-my-pi/pi-coding-agent/modes/setup-wizard/scene import { SetupWizardComponent } from "@oh-my-pi/pi-coding-agent/modes/setup-wizard/wizard-overlay"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; -import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "@oh-my-pi/pi-coding-agent/web/search/types"; +import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; function fakeContextWithConfiguredModel(): InteractiveModeContext { return { @@ -487,13 +486,12 @@ describe("setup wizard glyph scene", () => { }); describe("setup wizard web search tab", () => { - it("exposes every web-search provider preference in the schema-backed TUI list", () => { - const schema = SETTINGS_SCHEMA["providers.webSearch"]; - expect(schema.values).toEqual(SEARCH_PROVIDER_PREFERENCES); - expect(schema.ui.options).toEqual(SEARCH_PROVIDER_OPTIONS); + it("exposes every web-search provider preference in the shared TUI list", () => { + expect(SEARCH_PROVIDER_OPTIONS[0]?.value).toBe("auto"); + expect(SEARCH_PROVIDER_OPTIONS.slice(1).map(option => option.value)).toEqual([...SEARCH_PROVIDER_ORDER]); }); - it("persists the highlighted provider as the web search preference", async () => { + it("persists the highlighted provider as the head of the web search order", async () => { const settings = Settings.isolated(); const host = { ctx: { @@ -511,9 +509,12 @@ describe("setup wizard web search tab", () => { tab.handleInput("\n"); // confirm the highlighted provider await Bun.sleep(20); - const expected = SETTINGS_SCHEMA["providers.webSearch"].ui.options[1].value; + const expected = SEARCH_PROVIDER_OPTIONS[1]!.value; expect(expected).not.toBe("auto"); - expect(settings.get("providers.webSearch")).toBe(expected); + expect(settings.get("providers.webSearchOrder")).toEqual([ + expected, + ...SEARCH_PROVIDER_ORDER.filter(id => id !== expected), + ]); }); it("can select the last provider in the setup TUI list", async () => { @@ -537,7 +538,12 @@ describe("setup wizard web search tab", () => { await Bun.sleep(20); const lastOption = SEARCH_PROVIDER_OPTIONS[SEARCH_PROVIDER_OPTIONS.length - 1]!; - expect(settings.get("providers.webSearch")).toBe(lastOption.value); + const lastValue = lastOption.value; + if (lastValue === "auto") throw new Error("last option must be a concrete provider"); + expect(settings.get("providers.webSearchOrder")).toEqual([ + lastValue, + ...SEARCH_PROVIDER_ORDER.filter(id => id !== lastValue), + ]); }); }); diff --git a/packages/coding-agent/test/tools/image-gen.test.ts b/packages/coding-agent/test/tools/image-gen.test.ts index 9f0162889..fd6abef32 100644 --- a/packages/coding-agent/test/tools/image-gen.test.ts +++ b/packages/coding-agent/test/tools/image-gen.test.ts @@ -7,7 +7,7 @@ import { getImageGenTools, getImageGenToolsWithRegistry, imageGenTool, - setPreferredImageProvider, + setImageProviderOrder, } from "@oh-my-pi/pi-coding-agent/tools/image-gen"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; @@ -21,7 +21,7 @@ afterEach(async () => { } else { Bun.env.OPENROUTER_API_KEY = originalOpenRouterKey; } - setPreferredImageProvider("auto"); + setImageProviderOrder([]); }); function createAntigravityXAIContext(model: Model | undefined, fetchMock: typeof fetch): CustomToolContext { @@ -71,7 +71,7 @@ describe("imageGenTool", () => { }); it("resolves image provider credentials on execution", async () => { - setPreferredImageProvider("antigravity"); + setImageProviderOrder(["antigravity"]); const ctx: CustomToolContext = { fetch: async () => new Response(null), sessionManager: { @@ -165,7 +165,7 @@ describe("imageGenTool", () => { }); it("routes OpenAI Images edits through the Responses image tool", async () => { - setPreferredImageProvider("openai"); + setImageProviderOrder(["openai"]); let requestUrl: string | undefined; let requestBody: Record | undefined; @@ -236,7 +236,7 @@ describe("imageGenTool", () => { }); it("routes image generation through a connected Codex (ChatGPT) subscription when the active model is not OpenAI", async () => { - setPreferredImageProvider("openai-codex"); + setImageProviderOrder(["openai-codex"]); let requestUrl: string | undefined; let accountHeader: string | null | undefined; let requestBody: Record | undefined; @@ -387,9 +387,9 @@ describe("imageGenTool", () => { expect(result.details?.imageCount).toBe(1); }); - it("honors a per-request provider override over the providers.image setting", async () => { + it("honors a per-request provider override over the providers.imageOrder setting", async () => { // Setting selects Codex and a Codex subscription IS connected... - setPreferredImageProvider("openai-codex"); + setImageProviderOrder(["openai-codex"]); let requestUrl: string | undefined; const captured: { authorization: string | null } = { authorization: null }; @@ -569,7 +569,7 @@ describe("imageGenTool", () => { expect(result.details?.imageCount).toBe(1); }); it("routes xAI image generation with xAI-only aspect ratios", async () => { - setPreferredImageProvider("xai"); + setImageProviderOrder(["xai"]); let requestUrl: string | undefined; let requestBody: Record | undefined; const captured: { authorization: string | null; userAgent: string | null } = { diff --git a/packages/coding-agent/test/web/search/cli-provider-settings.test.ts b/packages/coding-agent/test/web/search/cli-provider-settings.test.ts index c7a3ae18c..a567a0c25 100644 --- a/packages/coding-agent/test/web/search/cli-provider-settings.test.ts +++ b/packages/coding-agent/test/web/search/cli-provider-settings.test.ts @@ -4,7 +4,7 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config import { SEARCH_PROVIDER_ORDER, setExcludedSearchProviders, - setPreferredSearchProvider, + setSearchProviderOrder, } from "@oh-my-pi/pi-coding-agent/web/search/provider"; import { __resetDirsFromEnvForTests, setAgentDir, TempDir } from "@oh-my-pi/pi-utils"; import { runSearchCommand } from "../../../src/cli/web-search-cli"; @@ -81,7 +81,7 @@ beforeEach(async () => { process.exitCode = undefined; resetSettingsForTest(); - setPreferredSearchProvider("auto"); + setSearchProviderOrder([]); setExcludedSearchProviders([]); tempAgentDir = TempDir.createSync("@omp-search-cli-"); setAgentDir(tempAgentDir.path()); @@ -89,7 +89,7 @@ beforeEach(async () => { inMemory: true, cwd: tempAgentDir.path(), overrides: { - "providers.webSearch": "tavily", + "providers.webSearchOrder": ["tavily"], "providers.webSearchExclude": ["jina"], }, }); @@ -98,7 +98,7 @@ beforeEach(async () => { afterEach(async () => { vi.restoreAllMocks(); resetSettingsForTest(); - setPreferredSearchProvider("auto"); + setSearchProviderOrder([]); setExcludedSearchProviders([]); process.exitCode = originalExitCode; for (const key of WEB_SEARCH_ENV_KEYS) { @@ -115,7 +115,7 @@ afterEach(async () => { }); describe("runSearchCommand provider settings", () => { - it("applies configured web-search preference and exclusions before resolving the implicit chain", async () => { + it("applies the configured web-search order and exclusions before resolving the implicit chain", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock()); let stdout = ""; @@ -131,23 +131,19 @@ describe("runSearchCommand provider settings", () => { expect(plain).not.toContain("Provider: Jina"); }); - it("treats explicit --provider auto as a one-shot override of the configured preferred provider", async () => { - // Tavily is the configured preference, but `--provider auto` overrides it and walks the - // chain. Restrict eligibility to Jina + Tavily so an ambient broker/OAuth provider - // (gemini, anthropic, codex, perplexity…) can't win on a dev machine; the chain order - // (Jina before Tavily) still decides between the two. + it("treats an explicit --provider as a one-shot override of the configured order", async () => { + // Tavily heads the configured order, but an explicit `--provider jina` + // forces Jina for this invocation without touching the configured chain. const currentTempDir = tempAgentDir; if (!currentTempDir) throw new Error("tempAgentDir missing"); - // Drive the exclusion through settings too — Settings.init re-applies - // `providers.webSearchExclude`, overwriting a bare setExcludedSearchProviders() call. const onlyJinaTavily = SEARCH_PROVIDER_ORDER.filter(id => id !== "jina" && id !== "tavily"); resetSettingsForTest(); - setPreferredSearchProvider("auto"); + setSearchProviderOrder([]); setExcludedSearchProviders(onlyJinaTavily); await Settings.init({ inMemory: true, cwd: currentTempDir.path(), - overrides: { "providers.webSearch": "tavily", "providers.webSearchExclude": onlyJinaTavily }, + overrides: { "providers.webSearchOrder": ["tavily"], "providers.webSearchExclude": onlyJinaTavily }, }); vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock()); @@ -158,7 +154,7 @@ describe("runSearchCommand provider settings", () => { return true; }); - await runSearchCommand({ query: "explicit auto chain", provider: "auto", limit: 1, expanded: false }); + await runSearchCommand({ query: "explicit provider override", provider: "jina", limit: 1, expanded: false }); const plain = stripVTControlCharacters(stdout); expect(plain).toContain("Provider: Jina"); diff --git a/packages/coding-agent/test/web/search/provider-chain.test.ts b/packages/coding-agent/test/web/search/provider-chain.test.ts index aaf998398..7a6044ab4 100644 --- a/packages/coding-agent/test/web/search/provider-chain.test.ts +++ b/packages/coding-agent/test/web/search/provider-chain.test.ts @@ -5,7 +5,6 @@ import { resolveProviderCandidates, resolveProviderChain, setExcludedSearchProviders, - setPreferredSearchProvider, setSearchProviderOrder, } from "@oh-my-pi/pi-coding-agent/web/search/provider"; import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; @@ -34,14 +33,13 @@ function restoreEnv(): void { } afterEach(() => { - setPreferredSearchProvider("auto"); setExcludedSearchProviders([]); setSearchProviderOrder([]); restoreEnv(); }); describe("resolveProviderCandidates", () => { - it("orders the preferred provider before configured and built-in fallbacks", () => { + it("orders the forced provider before configured and built-in fallbacks", () => { setSearchProviderOrder(["gemini", "exa"]); const candidates = resolveProviderCandidates("perplexity"); @@ -54,6 +52,15 @@ describe("resolveProviderCandidates", () => { ]); }); + it("marks configured-order entries explicit so hand-listed providers keep explicit-selection semantics", () => { + setSearchProviderOrder(["perplexity"]); + + const candidates = resolveProviderCandidates(); + + expect(candidates[0]).toEqual({ id: "perplexity", explicit: true }); + expect(candidates[1]?.explicit).toBe(false); + }); + it("omits excluded providers without resolving them", () => { setExcludedSearchProviders(["duckduckgo", "google"]); @@ -68,7 +75,7 @@ describe("resolveProviderCandidates", () => { controller.handleSettingChange("providers.webSearchOrder", ["exa", "not-a-provider", "exa", "gemini"]); - const candidates = resolveProviderCandidates("auto"); + const candidates = resolveProviderCandidates(); expect(candidates.slice(0, 2).map(candidate => candidate.id)).toEqual(["exa", "gemini"]); expect(candidates).toHaveLength(SEARCH_PROVIDER_ORDER.length); }); @@ -79,12 +86,12 @@ describe("resolveProviderChain", () => { enableKeyBackedProviders(); setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina")); - const providers = await resolveProviderChain(authStorage, "auto"); + const providers = await resolveProviderChain(authStorage); expect(providers.map(provider => provider.id)).toEqual(["jina"]); }); - it("ignores the preferred provider when it is excluded", async () => { + it("ignores the forced provider when it is excluded", async () => { enableKeyBackedProviders(); setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina")); @@ -102,7 +109,7 @@ describe("resolveProviderChain", () => { SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"), ); - const providers = await resolveProviderChain(authStorage, "auto"); + const providers = await resolveProviderChain(authStorage); expect(providers.map(provider => provider.id)).toEqual(["jina"]); });