From 165dbc710ec74d3f5cf2669d3b6cca88746ffb2e Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 8 Jul 2026 11:47:04 +0000 Subject: [PATCH 1/6] feat(executors): Fable/Claude-5 model family, ultracode effort level, free-text model entry + effort for preset models --- crates/executors/src/executors/claude.rs | 79 ++++++++++++++- crates/executors/src/model_selector.rs | 2 + packages/ui/src/components/ModelList.tsx | 44 ++++++++- .../src/components/ModelSelectorPopover.tsx | 7 +- .../web-core/src/i18n/locales/en/common.json | 3 +- .../web-core/src/i18n/locales/es/common.json | 3 +- .../web-core/src/i18n/locales/fr/common.json | 3 +- .../web-core/src/i18n/locales/ja/common.json | 3 +- .../web-core/src/i18n/locales/ko/common.json | 3 +- .../src/i18n/locales/zh-Hans/common.json | 3 +- .../src/i18n/locales/zh-Hant/common.json | 3 +- .../src/shared/lib/modelSelector.test.ts | 95 +++++++++++++++++++ .../web-core/src/shared/lib/modelSelector.ts | 28 +++++- shared/schemas/claude_code.json | 1 + shared/types.ts | 2 +- 15 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 packages/web-core/src/shared/lib/modelSelector.test.ts diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 2fb0bd12d4c..53045f002c6 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -192,6 +192,35 @@ pub enum ClaudeEffort { High, XHigh, Max, + // Undocumented accepted `--effort` alias on the interactive CLI (2.1.204): + // resolves to `xhigh` plus standing dynamic-workflow orchestration for the + // session. Only the interactive `claude` TUI understands the alias — the + // pinned headless CLI (2.1.154) hard-rejects unknown values, so the headless + // path maps it to its underlying `xhigh` tier (see + // `ClaudeEffort::headless_arg`). Plain `//` (not `///`) keeps the generated + // JSON schema a flat string enum for the settings agent-config editor. + Ultracode, +} + +impl ClaudeEffort { + /// Effort value to pass to the pinned headless CLI (`@2.1.154`). The + /// `ultracode` alias does not exist there (it warns-and-ignores unknown + /// values), and the dynamic-workflow orchestration it enables is + /// interactive-only, so headless runs use the equivalent `xhigh` tier. + fn headless_arg(&self) -> &'static str { + match self { + ClaudeEffort::Low => "low", + ClaudeEffort::Medium => "medium", + ClaudeEffort::High => "high", + ClaudeEffort::XHigh => "xhigh", + ClaudeEffort::Max => "max", + // 2.1.154 hard-rejects the `ultracode` alias ("argument is invalid. + // It must be one of: low, medium, high, xhigh, max"), and its + // dynamic-workflow orchestration is interactive-only, so headless + // runs use the underlying xhigh tier. + ClaudeEffort::Ultracode => "xhigh", + } + } } #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] @@ -260,7 +289,7 @@ impl ClaudeCode { builder = builder.extend_params(["--model", model.as_str()]); } if let Some(effort) = &self.effort { - builder = builder.extend_params(["--effort", effort.as_ref()]); + builder = builder.extend_params(["--effort", effort.headless_arg()]); } if let Some(agent) = &self.agent { builder = builder.extend_params(["--agent", agent]); @@ -346,7 +375,7 @@ impl ClaudeCode { /// Models whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet /// families). Haiku has no effort control. pub(crate) fn model_supports_effort(id: &str) -> bool { - id.contains("opus") || id.contains("sonnet") + id.contains("opus") || id.contains("sonnet") || id.contains("fable") } /// Claude model ids use hyphens in the version (e.g. `claude-opus-4-8`), but a @@ -426,6 +455,49 @@ mod cli_launch_tests { ); } + #[test] + fn fable_models_carry_effort() { + // Regression: model_supports_effort() previously excluded fable, so + // CLI-mode launches of claude-fable-5 silently dropped --effort. + assert_eq!( + interactive_cli_args(Some("claude-fable-5"), Some("xhigh")), + v(&["--model", "claude-fable-5", "--effort", "xhigh"]) + ); + assert_eq!( + interactive_cli_args(Some("fable"), Some("ultracode")), + v(&["--model", "fable", "--effort", "ultracode"]) + ); + } + + #[test] + fn interactive_passes_ultracode_verbatim() { + // The interactive CLI (2.1.204) understands the `ultracode` alias, so it + // must be forwarded unchanged rather than mapped to xhigh. + assert_eq!( + interactive_cli_args(Some("opus"), Some("ultracode")), + v(&["--model", "opus", "--effort", "ultracode"]) + ); + } + + use super::ClaudeEffort; + + #[test] + fn ultracode_effort_round_trips() { + let effort: ClaudeEffort = "ultracode".parse().unwrap(); + assert_eq!(effort, ClaudeEffort::Ultracode); + assert_eq!(effort.as_ref(), "ultracode"); + } + + #[test] + fn ultracode_maps_to_xhigh_in_headless() { + // The pinned headless CLI (2.1.154) does not know the `ultracode` alias, + // and its dynamic-workflow orchestration is interactive-only, so headless + // runs fall back to the underlying xhigh tier. + assert_eq!(ClaudeEffort::Ultracode.headless_arg(), "xhigh"); + assert_eq!(ClaudeEffort::XHigh.headless_arg(), "xhigh"); + assert_eq!(ClaudeEffort::Max.headless_arg(), "max"); + } + #[test] fn blank_values_fall_back_to_defaults() { assert_eq!( @@ -513,7 +585,7 @@ fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscovered }; let effort_options = ReasoningOption::from_names_with_default( - ["low", "medium", "high", "xhigh", "max"].map(String::from), + ["low", "medium", "high", "xhigh", "max", "ultracode"].map(String::from), CLI_DEFAULT_EFFORT, ); @@ -521,6 +593,7 @@ fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscovered model_selector: ModelSelectorConfig { providers: vec![], models: [ + ("fable", "Fable"), ("opus", "Opus"), ("opus[1m]", "Opus (1M context)"), ("sonnet", "Sonnet"), diff --git a/crates/executors/src/model_selector.rs b/crates/executors/src/model_selector.rs index daa38f4fe3c..618299aa487 100644 --- a/crates/executors/src/model_selector.rs +++ b/crates/executors/src/model_selector.rs @@ -118,6 +118,7 @@ impl ReasoningOption { "high" => Some(3), "xhigh" => Some(4), "max" => Some(5), + "ultracode" => Some(6), _ => None, }; @@ -148,6 +149,7 @@ impl ReasoningOption { fn reasoning_label(id: &str) -> String { match id { "xhigh" => "Extra High".to_string(), + "ultracode" => "Ultracode".to_string(), _ => id.to_case(Case::Title), } } diff --git a/packages/ui/src/components/ModelList.tsx b/packages/ui/src/components/ModelList.tsx index aa85c16fed4..3e3410fc320 100644 --- a/packages/ui/src/components/ModelList.tsx +++ b/packages/ui/src/components/ModelList.tsx @@ -117,6 +117,13 @@ export interface ModelListProps { showDefaultOption?: boolean; onSelectDefault?: () => void; scrollRef?: Ref; + /** + * When provided, a "Use \"\"" row is offered whenever the trimmed + * search query matches no model, letting the user select an arbitrary + * (custom) model id. The downstream override chain tolerates ids not present + * in the config. + */ + onUseCustomModel?: (id: string) => void; } export function ModelList({ @@ -132,9 +139,11 @@ export function ModelList({ showDefaultOption = false, onSelectDefault, scrollRef, + onUseCustomModel, }: ModelListProps) { const { t } = useTranslation('common'); - const normalizedSearch = searchQuery.trim().toLowerCase(); + const trimmedSearch = searchQuery.trim(); + const normalizedSearch = trimmedSearch.toLowerCase(); const filteredModels = normalizedSearch ? models.filter((model) => { @@ -144,7 +153,37 @@ export function ModelList({ }) : models; - const showEmptyState = filteredModels.length === 0 && !showDefaultOption; + const showCustomEntry = + Boolean(onUseCustomModel) && + trimmedSearch.length > 0 && + filteredModels.length === 0; + const showEmptyState = + filteredModels.length === 0 && !showDefaultOption && !showCustomEntry; + + const customEntryRow = showCustomEntry ? ( +
+ +
+ ) : null; const isDefaultSelected = selectedModelId === null; const normalizedSelectedId = selectedModelId?.toLowerCase() ?? null; @@ -269,6 +308,7 @@ export function ModelList({ ); })} + {customEntryRow} {defaultRow} )} diff --git a/packages/ui/src/components/ModelSelectorPopover.tsx b/packages/ui/src/components/ModelSelectorPopover.tsx index 2e98f9a1a76..d44e5d6a7d7 100644 --- a/packages/ui/src/components/ModelSelectorPopover.tsx +++ b/packages/ui/src/components/ModelSelectorPopover.tsx @@ -50,8 +50,6 @@ export interface ModelSelectorPopoverProps { resolvedTheme?: 'light' | 'dark'; } -const MODEL_LIST_PAGE_SIZE = 8; - function getModelKey(model: ModelListModel): string { return model.provider_id ? `${model.provider_id}/${model.id}` : model.id; } @@ -335,7 +333,9 @@ export function ModelSelectorPopover({ selectedProviderId, selectedModelId ); - showSearch = models.length > MODEL_LIST_PAGE_SIZE; + // Always offer the search box for the flat (no-provider) picker so a custom + // model id can be typed even when the built-in list is short. + showSearch = true; content = ( onModelSelect(id)} /> ); } diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 2fdbb996bf1..5528e866b2a 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -491,7 +491,8 @@ "permissionPlan": "Plan", "agent": "Agent", "default": "Default", - "model": "Model" + "model": "Model", + "useCustom": "Use \"{{query}}\"" }, "crashScreen": { "title": "Something went wrong", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 69bfad73312..c5ecb49a351 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -477,7 +477,8 @@ "permissionPlan": "Plan", "agent": "Agente", "default": "Predeterminado", - "model": "Modelo" + "model": "Modelo", + "useCustom": "Usar \"{{query}}\"" }, "crashScreen": { "title": "Algo salió mal", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 68aad865352..11bc9b8f919 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -477,7 +477,8 @@ "permissionPlan": "Plan", "agent": "Agent", "default": "Par défaut", - "model": "Modèle" + "model": "Modèle", + "useCustom": "Utiliser \"{{query}}\"" }, "crashScreen": { "title": "Une erreur est survenue", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index be469d84da3..dd4f99aa4d8 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -477,7 +477,8 @@ "permissionPlan": "計画", "agent": "エージェント", "default": "デフォルト", - "model": "モデル" + "model": "モデル", + "useCustom": "\"{{query}}\" を使用" }, "crashScreen": { "title": "問題が発生しました", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index 35cbed41e21..11c47712c32 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -477,7 +477,8 @@ "permissionPlan": "계획", "agent": "에이전트", "default": "기본값", - "model": "모델" + "model": "모델", + "useCustom": "\"{{query}}\" 사용" }, "crashScreen": { "title": "문제가 발생했습니다", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 93bcdbfaf76..b6049ad9ad0 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -477,7 +477,8 @@ "permissionPlan": "计划", "agent": "代理", "default": "默认", - "model": "模型" + "model": "模型", + "useCustom": "使用 \"{{query}}\"" }, "crashScreen": { "title": "出了点问题", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 93806fed3cf..768cc10bf13 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -477,7 +477,8 @@ "permissionPlan": "計畫", "agent": "代理", "default": "預設", - "model": "模型" + "model": "模型", + "useCustom": "使用 \"{{query}}\"" }, "crashScreen": { "title": "發生了錯誤", diff --git a/packages/web-core/src/shared/lib/modelSelector.test.ts b/packages/web-core/src/shared/lib/modelSelector.test.ts new file mode 100644 index 00000000000..2b0dbfdda48 --- /dev/null +++ b/packages/web-core/src/shared/lib/modelSelector.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import type { ModelSelectorConfig, ReasoningOption } from 'shared/types'; + +import { appendPresetModel } from './modelSelector'; + +const effortOptions: ReasoningOption[] = [ + { id: 'low', label: 'Low', is_default: false }, + { id: 'high', label: 'High', is_default: true }, + { id: 'ultracode', label: 'Ultracode', is_default: false }, +]; + +function claudeConfig(): ModelSelectorConfig { + return { + providers: [], + models: [ + { + id: 'opus', + name: 'Opus', + provider_id: null, + reasoning_options: effortOptions, + }, + { + id: 'haiku', + name: 'Haiku', + provider_id: null, + reasoning_options: [], + }, + ], + default_model: 'opus', + agents: [], + permissions: [], + }; +} + +function providerConfig(): ModelSelectorConfig { + return { + providers: [{ id: 'openai', name: 'OpenAI' }], + models: [ + { + id: 'gpt-5', + name: 'GPT-5', + provider_id: 'openai', + reasoning_options: [{ id: 'high', label: 'High', is_default: true }], + }, + ], + default_model: 'openai/gpt-5', + agents: [], + permissions: [], + }; +} + +describe('appendPresetModel', () => { + it('injects an effort-capable Claude preset with inherited reasoning options', () => { + const result = appendPresetModel(claudeConfig(), 'claude-fable-5'); + expect(result).not.toBeNull(); + const injected = result!.models[0]; + expect(injected.id).toBe('claude-fable-5'); + expect(injected.provider_id).toBeNull(); + // Fable is effort-capable, so it inherits the effort options from the first + // config model that has them (opus) — including ultracode. + expect(injected.reasoning_options.map((o) => o.id)).toEqual([ + 'low', + 'high', + 'ultracode', + ]); + }); + + it('does not invent effort options for a non-effort-capable preset', () => { + const result = appendPresetModel(claudeConfig(), 'some-random-model'); + expect(result!.models[0].id).toBe('some-random-model'); + expect(result!.models[0].reasoning_options).toEqual([]); + }); + + it('leaves provider-scoped presets without inherited effort options', () => { + // codex-style providers path: the injected id belongs to a provider, so no + // reasoning fallback is applied even if the id string matched the heuristic. + const result = appendPresetModel(providerConfig(), 'openai/opus-custom'); + const injected = result!.models[0]; + expect(injected.id).toBe('opus-custom'); + expect(injected.provider_id).toBe('openai'); + expect(injected.reasoning_options).toEqual([]); + }); + + it('returns the config unchanged when the preset already exists', () => { + const config = claudeConfig(); + const result = appendPresetModel(config, 'opus'); + expect(result).toBe(config); + }); + + it('returns the config unchanged when no preset is given', () => { + const config = claudeConfig(); + expect(appendPresetModel(config, null)).toBe(config); + expect(appendPresetModel(config, undefined)).toBe(config); + }); +}); diff --git a/packages/web-core/src/shared/lib/modelSelector.ts b/packages/web-core/src/shared/lib/modelSelector.ts index 2337cd7a279..ddab936497d 100644 --- a/packages/web-core/src/shared/lib/modelSelector.ts +++ b/packages/web-core/src/shared/lib/modelSelector.ts @@ -66,6 +66,20 @@ export function parseModelId( }; } +/** + * Claude model ids whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet + * / Fable families). Mirrors `model_supports_effort` in the Rust executor. Used + * to decide whether an injected/custom model should inherit effort options. + */ +function modelSupportsEffort(id: string): boolean { + const lower = id.toLowerCase(); + return ( + lower.includes('opus') || + lower.includes('sonnet') || + lower.includes('fable') + ); +} + export function appendPresetModel( config: ModelSelectorConfig | null, presetModel: string | null | undefined @@ -82,6 +96,18 @@ export function appendPresetModel( ); if (exists) return config; + // An injected/custom model has no reasoning options of its own. If it looks + // like an effort-capable Claude model, inherit the effort choices from the + // first config model that has them so the user can still pick an effort + // (e.g. Miguel's preset `claude-fable-5`). Gated on `modelSupportsEffort` so + // executors whose models legitimately have no reasoning options (e.g. codex + // providers) are unaffected. + const reasoningOptions = + !providerId && modelSupportsEffort(modelId) + ? (config.models.find((m) => m.reasoning_options.length > 0) + ?.reasoning_options ?? []) + : []; + return { ...config, models: [ @@ -89,7 +115,7 @@ export function appendPresetModel( id: modelId, name: modelId, provider_id: providerId, - reasoning_options: [], + reasoning_options: reasoningOptions, }, ...config.models, ], diff --git a/shared/schemas/claude_code.json b/shared/schemas/claude_code.json index 94d023656ab..d5e1134954a 100644 --- a/shared/schemas/claude_code.json +++ b/shared/schemas/claude_code.json @@ -46,6 +46,7 @@ "high", "xhigh", "max", + "ultracode", null ] }, diff --git a/shared/types.ts b/shared/types.ts index 778506992bd..cd82c3b0450 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -712,7 +712,7 @@ export type ExecutorConfigs = { executors: { [key in BaseCodingAgent]?: Executor export enum BaseAgentCapability { SESSION_FORK = "SESSION_FORK", SETUP_HELPER = "SETUP_HELPER", CONTEXT_USAGE = "CONTEXT_USAGE" } -export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultracode"; export type ClaudeCode = { append_prompt: AppendPrompt, claude_code_router?: boolean | null, plan?: boolean | null, approvals?: boolean | null, model?: string | null, effort?: ClaudeEffort | null, agent?: string | null, dangerously_skip_permissions?: boolean | null, disable_api_key?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; From 3193fd57f16e5d8ce336bfa782e217fe7963c7e0 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 8 Jul 2026 16:13:53 +0000 Subject: [PATCH 2/6] test(executors): pin effort wire strings + headless_arg invariant; make model_supports_effort case-insensitive Council round 1 hardening for the Claude effort changes: - Fix headless_arg doc comment (2.1.154 hard-rejects unknown --effort values, not warn-and-ignore) to match verified CLI behavior and the match-arm comment. - Add headless_arg_matches_as_ref_except_ultracode: pins that headless mode emits the same wire value as the interactive path for every tier except the deliberate ultracode->xhigh delta, so a future strum serialization change can't silently diverge the two paths. - Add effort_wire_strings_are_stable golden test enforcing the locked ADD-only never-rename policy for ClaudeEffort (serialized into profiles.json + stored ExecutorActions history) at CI time instead of by comment alone. - Make model_supports_effort case-insensitive so a free-text/custom id like Claude-Fable-5 is gated identically in the UI (TS mirror lowercases) and at interactive launch, closing a silent --effort drop; add reciprocal sync comment and a boundary test (haiku stays excluded). --- crates/executors/src/executors/claude.rs | 70 ++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 53045f002c6..ba43700f32d 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -204,9 +204,11 @@ pub enum ClaudeEffort { impl ClaudeEffort { /// Effort value to pass to the pinned headless CLI (`@2.1.154`). The - /// `ultracode` alias does not exist there (it warns-and-ignores unknown - /// values), and the dynamic-workflow orchestration it enables is - /// interactive-only, so headless runs use the equivalent `xhigh` tier. + /// `ultracode` alias does not exist there (2.1.154 hard-rejects unknown + /// `--effort` values), and the dynamic-workflow orchestration it enables is + /// interactive-only, so headless runs use the equivalent `xhigh` tier. Every + /// other variant maps to the same string strum's `as_ref()` produces; only + /// `Ultracode` is a deliberate delta (see the `headless_arg` tests). fn headless_arg(&self) -> &'static str { match self { ClaudeEffort::Low => "low", @@ -372,9 +374,14 @@ impl ClaudeCode { } } -/// Models whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet -/// families). Haiku has no effort control. +/// Models whose CLI accepts a reasoning `--effort` flag (Opus / Sonnet / Fable +/// families). Haiku has no effort control. Matched case-insensitively so a +/// free-text / custom id like `Claude-Fable-5` is gated the same in the UI and +/// at launch (otherwise the interactive path would silently drop `--effort`). +/// Keep in sync with `modelSupportsEffort` in +/// `packages/web-core/src/shared/lib/modelSelector.ts`. pub(crate) fn model_supports_effort(id: &str) -> bool { + let id = id.to_lowercase(); id.contains("opus") || id.contains("sonnet") || id.contains("fable") } @@ -498,6 +505,59 @@ mod cli_launch_tests { assert_eq!(ClaudeEffort::Max.headless_arg(), "max"); } + #[test] + fn headless_arg_matches_as_ref_except_ultracode() { + // Invariant: the headless CLI receives the same value as the interactive + // path (strum's `as_ref()`) for every tier EXCEPT `ultracode`, which the + // pinned @2.1.154 headless CLI does not accept and so maps to `xhigh`. + // If someone customizes a variant's serialized form (e.g. a strum + // `serialize = "..."`), this pins that both paths keep agreeing. + for effort in [ + ClaudeEffort::Low, + ClaudeEffort::Medium, + ClaudeEffort::High, + ClaudeEffort::XHigh, + ClaudeEffort::Max, + ] { + assert_eq!( + effort.headless_arg(), + effort.as_ref(), + "{effort:?} must map to its interactive wire value in headless mode" + ); + } + // The one deliberate delta. + assert_eq!(ClaudeEffort::Ultracode.as_ref(), "ultracode"); + assert_eq!(ClaudeEffort::Ultracode.headless_arg(), "xhigh"); + } + + #[test] + fn effort_wire_strings_are_stable() { + // ClaudeEffort is serialized into profiles.json and stored + // ExecutorActions history. Renaming/reordering a variant would corrupt + // that persisted data, so pin the exact on-the-wire string of every + // variant. ADD-only: new variants extend this list, never mutate it. + assert_eq!(ClaudeEffort::Low.as_ref(), "low"); + assert_eq!(ClaudeEffort::Medium.as_ref(), "medium"); + assert_eq!(ClaudeEffort::High.as_ref(), "high"); + assert_eq!(ClaudeEffort::XHigh.as_ref(), "xhigh"); + assert_eq!(ClaudeEffort::Max.as_ref(), "max"); + assert_eq!(ClaudeEffort::Ultracode.as_ref(), "ultracode"); + } + + #[test] + fn model_supports_effort_boundary() { + use super::model_supports_effort; + // Effort-capable families (any id form, case-insensitive). + assert!(model_supports_effort("opus")); + assert!(model_supports_effort("opus[1m]")); + assert!(model_supports_effort("sonnet")); + assert!(model_supports_effort("claude-fable-5")); + assert!(model_supports_effort("Claude-Fable-5")); // mirrors the TS lowercasing + // Haiku has no effort control — keep it out. + assert!(!model_supports_effort("haiku")); + assert!(!model_supports_effort("claude-haiku-4-5-20251001")); + } + #[test] fn blank_values_fall_back_to_defaults() { assert_eq!( From 44650e12fa54481427e6294357b09eb584b17e8e Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 8 Jul 2026 16:35:05 +0000 Subject: [PATCH 3/6] refactor(executors): derive effort list from ClaudeEffort; drop dead code /simplify pass on the effort/model changes (behavior-preserving): - Derive the picker's effort_options from ClaudeEffort::VARIANTS (strum VariantNames) instead of a hand-maintained string array, so a future effort tier can't be silently missing from the list; pin the derived wire strings in a test. - Drop the redundant reasoning_label "ultracode" arm: the Title-case fallback already yields "Ultracode" (like low/medium/high/max); only xhigh needs an override. - ModelSelectorPopover: remove the vestigial showSearch variable (always true since MODEL_LIST_PAGE_SIZE was removed) and render the search box directly; document the deliberate flat-picker-only scope of the custom-model entry. Kept the explicit exhaustive headless_arg match rather than collapsing to `_ => self.as_ref()`: the compile-time exhaustiveness forces every future variant to make a headless decision at the version-skew boundary (a wildcard would let a future ultracode-like alias silently reach the pinned headless CLI). --- crates/executors/src/executors/claude.rs | 35 ++++++++++++++++--- crates/executors/src/model_selector.rs | 4 ++- .../src/components/ModelSelectorPopover.tsx | 28 +++++++-------- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index ba43700f32d..6e34c27698b 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -181,9 +181,20 @@ fn normalize_claude_stderr_logs( } use derivative::Derivative; -use strum_macros::{AsRefStr, EnumString}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS, JsonSchema, AsRefStr, EnumString)] +use strum_macros::{AsRefStr, EnumString, VariantNames}; + +#[derive( + Debug, + Clone, + PartialEq, + Serialize, + Deserialize, + TS, + JsonSchema, + AsRefStr, + EnumString, + VariantNames, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ClaudeEffort { @@ -542,6 +553,15 @@ mod cli_launch_tests { assert_eq!(ClaudeEffort::XHigh.as_ref(), "xhigh"); assert_eq!(ClaudeEffort::Max.as_ref(), "max"); assert_eq!(ClaudeEffort::Ultracode.as_ref(), "ultracode"); + + // The picker's effort list is derived from these VARIANTS (strum + // VariantNames honoring serialize_all="lowercase"); pin the exact set so + // a rename or a strum casing change can't silently corrupt the wire ids. + use strum::VariantNames; + assert_eq!( + ClaudeEffort::VARIANTS, + ["low", "medium", "high", "xhigh", "max", "ultracode"] + ); } #[test] @@ -639,13 +659,18 @@ mod cli_launch_tests { } fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions { + // Derived from the ClaudeEffort enum (strum `VariantNames`, lowercased) so a + // future effort tier automatically appears in the picker instead of silently + // missing from this list. `from_names_with_default` sorts by rank, so the + // declaration order here is irrelevant. + use strum::VariantNames; + use crate::{ executor_discovery::ExecutorDiscoveredOptions, model_selector::{ModelInfo, ModelSelectorConfig, ReasoningOption}, }; - let effort_options = ReasoningOption::from_names_with_default( - ["low", "medium", "high", "xhigh", "max", "ultracode"].map(String::from), + ClaudeEffort::VARIANTS.iter().copied(), CLI_DEFAULT_EFFORT, ); diff --git a/crates/executors/src/model_selector.rs b/crates/executors/src/model_selector.rs index 618299aa487..b16baf2bf05 100644 --- a/crates/executors/src/model_selector.rs +++ b/crates/executors/src/model_selector.rs @@ -148,8 +148,10 @@ impl ReasoningOption { fn reasoning_label(id: &str) -> String { match id { + // Only override tiers the Title-case fallback would render wrong. + // "xhigh" -> "Xhigh" is wrong; "ultracode" -> "Ultracode" is already + // correct via the fallback, like low/medium/high/max. "xhigh" => "Extra High".to_string(), - "ultracode" => "Ultracode".to_string(), _ => id.to_case(Case::Title), } } diff --git a/packages/ui/src/components/ModelSelectorPopover.tsx b/packages/ui/src/components/ModelSelectorPopover.tsx index d44e5d6a7d7..e3e121be639 100644 --- a/packages/ui/src/components/ModelSelectorPopover.tsx +++ b/packages/ui/src/components/ModelSelectorPopover.tsx @@ -305,7 +305,6 @@ export function ModelSelectorPopover({ const popoverWidth = getPopoverWidth(hasProviders, hasReasoning); const popoverHeightClass = hasProviders ? 'h-[280px]' : ''; - let showSearch = true; let content: ReactElement; if (hasProviders) { @@ -333,10 +332,6 @@ export function ModelSelectorPopover({ selectedProviderId, selectedModelId ); - // Always offer the search box for the flat (no-provider) picker so a custom - // model id can be typed even when the built-in list is short. - showSearch = true; - content = ( onModelSelect(id)} /> ); @@ -384,15 +383,16 @@ export function ModelSelectorPopover({ {t('modelSelector.model')}
{content} - {showSearch && ( -
- -
- )} + {/* The search box is always shown: it filters the list and, for the + flat (no-provider) picker, lets a custom model id be typed even + when the built-in list is short. */} +
+ +
From c90b75cad72437f559825c6e219cddc1ff30981a Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 8 Jul 2026 16:35:05 +0000 Subject: [PATCH 4/6] fix(model-selector): effort options for free-text custom models; gate to Claude Addresses a Codex xhigh review of the picker changes: - appendPresetModel now also injects the current override model (which may be a free-text custom id the user typed), not just the profile preset, so a custom effort-capable Claude id shows an effort selector instead of silently having none (and headless launches then carry the chosen --effort). - Gate the reasoning-options fallback behind an explicit enableEffortFallback flag, set only for CLAUDE_CODE. Keeps the shared appendPresetModel helper executor-agnostic: previously any provider-less executor whose id happened to contain opus/sonnet/fable would inherit effort options it does not support. - Extend the vitest spec: custom-id inheritance + fallback-disabled cases. --- .../components/ModelSelectorContainer.tsx | 20 +++++++++-- .../src/shared/lib/modelSelector.test.ts | 36 +++++++++++++++---- .../web-core/src/shared/lib/modelSelector.ts | 19 +++++----- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx index 524b7b84043..b0c3f8d7f49 100644 --- a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx +++ b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx @@ -9,8 +9,8 @@ import { SlidersHorizontalIcon, type Icon, } from '@phosphor-icons/react'; -import type { BaseCodingAgent, ExecutorConfig, ModelInfo } from 'shared/types'; -import { PermissionPolicy } from 'shared/types'; +import type { ExecutorConfig, ModelInfo } from 'shared/types'; +import { BaseCodingAgent, PermissionPolicy } from 'shared/types'; import { toPrettyCase } from '@/shared/lib/string'; import { getModelKey, @@ -117,7 +117,21 @@ export function ModelSelectorContainer({ }, [streamError]); const baseConfig = streamConfig; - const config = appendPresetModel(baseConfig, presetOptions?.model_id); + // Inject both the profile preset and the current override model (which may be + // a free-text custom id typed by the user) so each shows up in the picker with + // an effort selector. Effort inheritance is enabled only for Claude, the sole + // executor that uses `--effort`. appendPresetModel is a no-op when the id is + // already present, so built-in / provider selections are untouched. + const enableEffortFallback = agent === BaseCodingAgent.CLAUDE_CODE; + const config = appendPresetModel( + appendPresetModel( + baseConfig, + presetOptions?.model_id, + enableEffortFallback + ), + executorConfig?.model_id, + enableEffortFallback + ); const availableProviderIds = useMemo( () => config?.providers.map((item) => item.id) ?? [], diff --git a/packages/web-core/src/shared/lib/modelSelector.test.ts b/packages/web-core/src/shared/lib/modelSelector.test.ts index 2b0dbfdda48..c9c0c840d87 100644 --- a/packages/web-core/src/shared/lib/modelSelector.test.ts +++ b/packages/web-core/src/shared/lib/modelSelector.test.ts @@ -51,7 +51,7 @@ function providerConfig(): ModelSelectorConfig { describe('appendPresetModel', () => { it('injects an effort-capable Claude preset with inherited reasoning options', () => { - const result = appendPresetModel(claudeConfig(), 'claude-fable-5'); + const result = appendPresetModel(claudeConfig(), 'claude-fable-5', true); expect(result).not.toBeNull(); const injected = result!.models[0]; expect(injected.id).toBe('claude-fable-5'); @@ -65,8 +65,28 @@ describe('appendPresetModel', () => { ]); }); + it('injects a free-text custom effort-capable id with inherited options', () => { + // Custom (typed) ids get the same effort fallback as the profile preset. + const result = appendPresetModel(claudeConfig(), 'claude-opus-4-8', true); + const injected = result!.models[0]; + expect(injected.id).toBe('claude-opus-4-8'); + expect(injected.reasoning_options.map((o) => o.id)).toEqual([ + 'low', + 'high', + 'ultracode', + ]); + }); + + it('does not inherit effort options when the fallback is disabled', () => { + // Non-Claude executors pass enableEffortFallback=false (the default): even a + // provider-less id containing "opus" must not invent effort options. + const result = appendPresetModel(claudeConfig(), 'claude-fable-5'); + expect(result!.models[0].id).toBe('claude-fable-5'); + expect(result!.models[0].reasoning_options).toEqual([]); + }); + it('does not invent effort options for a non-effort-capable preset', () => { - const result = appendPresetModel(claudeConfig(), 'some-random-model'); + const result = appendPresetModel(claudeConfig(), 'some-random-model', true); expect(result!.models[0].id).toBe('some-random-model'); expect(result!.models[0].reasoning_options).toEqual([]); }); @@ -74,7 +94,11 @@ describe('appendPresetModel', () => { it('leaves provider-scoped presets without inherited effort options', () => { // codex-style providers path: the injected id belongs to a provider, so no // reasoning fallback is applied even if the id string matched the heuristic. - const result = appendPresetModel(providerConfig(), 'openai/opus-custom'); + const result = appendPresetModel( + providerConfig(), + 'openai/opus-custom', + true + ); const injected = result!.models[0]; expect(injected.id).toBe('opus-custom'); expect(injected.provider_id).toBe('openai'); @@ -83,13 +107,13 @@ describe('appendPresetModel', () => { it('returns the config unchanged when the preset already exists', () => { const config = claudeConfig(); - const result = appendPresetModel(config, 'opus'); + const result = appendPresetModel(config, 'opus', true); expect(result).toBe(config); }); it('returns the config unchanged when no preset is given', () => { const config = claudeConfig(); - expect(appendPresetModel(config, null)).toBe(config); - expect(appendPresetModel(config, undefined)).toBe(config); + expect(appendPresetModel(config, null, true)).toBe(config); + expect(appendPresetModel(config, undefined, true)).toBe(config); }); }); diff --git a/packages/web-core/src/shared/lib/modelSelector.ts b/packages/web-core/src/shared/lib/modelSelector.ts index ddab936497d..f09dde8bfcd 100644 --- a/packages/web-core/src/shared/lib/modelSelector.ts +++ b/packages/web-core/src/shared/lib/modelSelector.ts @@ -82,7 +82,12 @@ function modelSupportsEffort(id: string): boolean { export function appendPresetModel( config: ModelSelectorConfig | null, - presetModel: string | null | undefined + presetModel: string | null | undefined, + // Only the Claude executor uses the `--effort` reasoning flag, so the caller + // opts in explicitly. Keeps this shared helper executor-agnostic: without it, + // any provider-less executor whose id happened to contain "opus"/"sonnet"/ + // "fable" would inherit effort options it does not support. + enableEffortFallback = false ): ModelSelectorConfig | null { if (!config || !presetModel) return config; const hasProviders = config.providers.length > 0; @@ -96,14 +101,12 @@ export function appendPresetModel( ); if (exists) return config; - // An injected/custom model has no reasoning options of its own. If it looks - // like an effort-capable Claude model, inherit the effort choices from the - // first config model that has them so the user can still pick an effort - // (e.g. Miguel's preset `claude-fable-5`). Gated on `modelSupportsEffort` so - // executors whose models legitimately have no reasoning options (e.g. codex - // providers) are unaffected. + // An injected/custom model has no reasoning options of its own. If effort is + // enabled and it looks like an effort-capable Claude model, inherit the effort + // choices from the first config model that has them so the user can still pick + // an effort (e.g. Miguel's preset `claude-fable-5`, or a free-text custom id). const reasoningOptions = - !providerId && modelSupportsEffort(modelId) + enableEffortFallback && !providerId && modelSupportsEffort(modelId) ? (config.models.find((m) => m.reasoning_options.length > 0) ?.reasoning_options ?? []) : []; From 987df99ad8d1008f628a77f012739eed8f484f79 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 8 Jul 2026 17:00:25 +0000 Subject: [PATCH 5/6] fix(model-selector): gate free-text custom-model entry to Claude only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-review (xhigh) caught that the free-text "Use " entry and the always-visible search box leaked to every flat (no-provider) executor — Codex, Gemini, Copilot, Droid, Qwen, Cursor are all flat pickers, not just Claude. That let a user type an arbitrary model id for executors whose downstream does not tolerate one (only Claude's was verified), and a bare typed id has no provider so it produced a malformed provider-less override; Codex custom models would also silently lose their reasoning options. - Add an explicit `allowCustomModel` prop to ModelSelectorPopover (default false); the container passes it only for CLAUDE_CODE. - Custom-entry row and the forced search box are now gated on it; other flat pickers keep their exact prior behavior (search only once the list exceeds MODEL_LIST_PAGE_SIZE, which is restored — the earlier /simplify collapse of showSearch assumed a single flat consumer, which was wrong). - Fold the effort-fallback + custom-entry gating into one `isClaude` flag. --- .../src/components/ModelSelectorPopover.tsx | 47 +++++++++++++------ .../components/ModelSelectorContainer.tsx | 22 ++++----- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/ModelSelectorPopover.tsx b/packages/ui/src/components/ModelSelectorPopover.tsx index e3e121be639..1b00823b058 100644 --- a/packages/ui/src/components/ModelSelectorPopover.tsx +++ b/packages/ui/src/components/ModelSelectorPopover.tsx @@ -48,8 +48,18 @@ export interface ModelSelectorPopoverProps { expandedProviderId?: string; onExpandedProviderIdChange?: (id: string) => void; resolvedTheme?: 'light' | 'dark'; + /** + * Enables the free-text "Use \"\"" custom-model row (and forces the + * search box on) for the flat picker. Off by default: only executors whose + * downstream tolerates arbitrary model ids (Claude) opt in; other flat + * executors keep their fixed model lists. + */ + allowCustomModel?: boolean; } +// Above this many models the flat picker shows a search box to filter the list. +const MODEL_LIST_PAGE_SIZE = 8; + function getModelKey(model: ModelListModel): string { return model.provider_id ? `${model.provider_id}/${model.id}` : model.id; } @@ -295,6 +305,7 @@ export function ModelSelectorPopover({ expandedProviderId = '', onExpandedProviderIdChange, resolvedTheme = 'light', + allowCustomModel = false, }: ModelSelectorPopoverProps) { const { t } = useTranslation('common'); const models = config.models; @@ -305,6 +316,10 @@ export function ModelSelectorPopover({ const popoverWidth = getPopoverWidth(hasProviders, hasReasoning); const popoverHeightClass = hasProviders ? 'h-[280px]' : ''; + // Provider (accordion) pickers always show the search box. The flat picker + // shows it once the list is long enough to warrant filtering, or whenever + // custom-model entry is allowed (which needs the box to type an id into). + let showSearch = true; let content: ReactElement; if (hasProviders) { @@ -332,6 +347,7 @@ export function ModelSelectorPopover({ selectedProviderId, selectedModelId ); + showSearch = allowCustomModel || models.length > MODEL_LIST_PAGE_SIZE; content = ( onModelSelect(id)} + // Free-text custom-model entry is gated on `allowCustomModel` so it only + // appears for executors that opt in (Claude) — a bare typed id has no + // provider, so exposing it to other flat/provider pickers would produce + // malformed provider-less overrides. + onUseCustomModel={ + allowCustomModel ? (id) => onModelSelect(id) : undefined + } /> ); } @@ -383,16 +401,15 @@ export function ModelSelectorPopover({ {t('modelSelector.model')}
{content} - {/* The search box is always shown: it filters the list and, for the - flat (no-provider) picker, lets a custom model id be typed even - when the built-in list is short. */} -
- -
+ {showSearch && ( +
+ +
+ )}
diff --git a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx index b0c3f8d7f49..440b6ab4698 100644 --- a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx +++ b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx @@ -117,20 +117,17 @@ export function ModelSelectorContainer({ }, [streamError]); const baseConfig = streamConfig; - // Inject both the profile preset and the current override model (which may be - // a free-text custom id typed by the user) so each shows up in the picker with - // an effort selector. Effort inheritance is enabled only for Claude, the sole - // executor that uses `--effort`. appendPresetModel is a no-op when the id is - // already present, so built-in / provider selections are untouched. - const enableEffortFallback = agent === BaseCodingAgent.CLAUDE_CODE; + // Claude is the only executor whose downstream tolerates arbitrary model ids + // and uses `--effort`, so it alone gets free-text custom entry + the effort + // fallback. Inject both the profile preset and the current override model + // (which may be a free-text custom id) so each shows up in the picker with an + // effort selector. appendPresetModel is a no-op when the id is already present, + // so built-in / provider selections are untouched. + const isClaude = agent === BaseCodingAgent.CLAUDE_CODE; const config = appendPresetModel( - appendPresetModel( - baseConfig, - presetOptions?.model_id, - enableEffortFallback - ), + appendPresetModel(baseConfig, presetOptions?.model_id, isClaude), executorConfig?.model_id, - enableEffortFallback + isClaude ); const availableProviderIds = useMemo( @@ -506,6 +503,7 @@ export function ModelSelectorContainer({ expandedProviderId={expandedProviderId} onExpandedProviderIdChange={setExpandedProviderId} resolvedTheme={resolvedTheme} + allowCustomModel={isClaude} /> )} From 14a6046f4b03d0afd60c3b3ab3038560eb9bcf6a Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 8 Jul 2026 17:16:03 +0000 Subject: [PATCH 6/6] refactor(model-selector): only inject the override model for Claude Council round 2: the override-model injection was unconditional (only the effort-fallback flag was gated on isClaude), so a non-Claude executor with a stale override id would still get it injected as a provider-less flat model. Benign (non-Claude pickers can't create free-text ids, so no malformed launch), but it widened behavior past the Claude-only intent. Gate the override injection on isClaude too; non-Claude executors now get exactly the pre-PR preset-only injection. --- .../components/ModelSelectorContainer.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx index 440b6ab4698..0030fafbbf4 100644 --- a/packages/web-core/src/shared/components/ModelSelectorContainer.tsx +++ b/packages/web-core/src/shared/components/ModelSelectorContainer.tsx @@ -119,16 +119,20 @@ export function ModelSelectorContainer({ const baseConfig = streamConfig; // Claude is the only executor whose downstream tolerates arbitrary model ids // and uses `--effort`, so it alone gets free-text custom entry + the effort - // fallback. Inject both the profile preset and the current override model - // (which may be a free-text custom id) so each shows up in the picker with an - // effort selector. appendPresetModel is a no-op when the id is already present, - // so built-in / provider selections are untouched. + // fallback. Always surface the profile preset (unchanged behavior). For Claude, + // additionally surface the current override — which may be a free-text custom + // id — so it shows in the picker with an effort selector; other executors never + // produce free-text overrides, so injecting theirs would only risk surfacing a + // stale id. appendPresetModel is a no-op when the id is already present. const isClaude = agent === BaseCodingAgent.CLAUDE_CODE; - const config = appendPresetModel( - appendPresetModel(baseConfig, presetOptions?.model_id, isClaude), - executorConfig?.model_id, + const configWithPreset = appendPresetModel( + baseConfig, + presetOptions?.model_id, isClaude ); + const config = isClaude + ? appendPresetModel(configWithPreset, executorConfig?.model_id, isClaude) + : configWithPreset; const availableProviderIds = useMemo( () => config?.providers.map((item) => item.id) ?? [],