Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 167 additions & 9 deletions crates/executors/src/executors/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -192,6 +203,37 @@ 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 (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",
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)]
Expand Down Expand Up @@ -260,7 +302,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]);
Expand Down Expand Up @@ -343,10 +385,15 @@ 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 {
id.contains("opus") || id.contains("sonnet")
let id = id.to_lowercase();
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
Expand Down Expand Up @@ -426,6 +473,111 @@ 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 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");

// 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]
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!(
Expand Down Expand Up @@ -507,20 +659,26 @@ 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"].map(String::from),
ClaudeEffort::VARIANTS.iter().copied(),
CLI_DEFAULT_EFFORT,
);

ExecutorDiscoveredOptions {
model_selector: ModelSelectorConfig {
providers: vec![],
models: [
("fable", "Fable"),
("opus", "Opus"),
("opus[1m]", "Opus (1M context)"),
("sonnet", "Sonnet"),
Expand Down
4 changes: 4 additions & 0 deletions crates/executors/src/model_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ impl ReasoningOption {
"high" => Some(3),
"xhigh" => Some(4),
"max" => Some(5),
"ultracode" => Some(6),
_ => None,
};

Expand Down Expand Up @@ -147,6 +148,9 @@ 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(),
_ => id.to_case(Case::Title),
}
Expand Down
44 changes: 42 additions & 2 deletions packages/ui/src/components/ModelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ export interface ModelListProps {
showDefaultOption?: boolean;
onSelectDefault?: () => void;
scrollRef?: Ref<HTMLDivElement>;
/**
* When provided, a "Use \"<query>\"" 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({
Expand All @@ -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) => {
Expand All @@ -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 ? (
<div
key="__custom__"
className={cn(
'group flex items-center rounded-sm mx-half',
'transition-colors duration-100',
'focus-within:bg-secondary',
'text-normal hover:bg-secondary/60'
)}
>
<button
type="button"
onClick={() => onUseCustomModel?.(trimmedSearch)}
className={cn(
'flex-1 min-w-0 py-half pl-base pr-half text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-brand'
)}
>
<span className="block text-sm truncate" title={trimmedSearch}>
{t('modelSelector.useCustom', { query: trimmedSearch })}
</span>
</button>
</div>
) : null;
const isDefaultSelected = selectedModelId === null;
const normalizedSelectedId = selectedModelId?.toLowerCase() ?? null;

Expand Down Expand Up @@ -269,6 +308,7 @@ export function ModelList({
</div>
);
})}
{customEntryRow}
{defaultRow}
</div>
)}
Expand Down
22 changes: 20 additions & 2 deletions packages/ui/src/components/ModelSelectorPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,16 @@ export interface ModelSelectorPopoverProps {
expandedProviderId?: string;
onExpandedProviderIdChange?: (id: string) => void;
resolvedTheme?: 'light' | 'dark';
/**
* Enables the free-text "Use \"<query>\"" 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 {
Expand Down Expand Up @@ -297,6 +305,7 @@ export function ModelSelectorPopover({
expandedProviderId = '',
onExpandedProviderIdChange,
resolvedTheme = 'light',
allowCustomModel = false,
}: ModelSelectorPopoverProps) {
const { t } = useTranslation('common');
const models = config.models;
Expand All @@ -307,6 +316,9 @@ 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;

Expand Down Expand Up @@ -335,8 +347,7 @@ export function ModelSelectorPopover({
selectedProviderId,
selectedModelId
);
showSearch = models.length > MODEL_LIST_PAGE_SIZE;

showSearch = allowCustomModel || models.length > MODEL_LIST_PAGE_SIZE;
content = (
<ModelList
models={sortedModels}
Expand All @@ -351,6 +362,13 @@ export function ModelSelectorPopover({
showDefaultOption={showDefaultOption}
onSelectDefault={onSelectDefault}
scrollRef={scrollRef}
// 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
}
/>
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/web-core/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@
"permissionPlan": "Plan",
"agent": "Agent",
"default": "Default",
"model": "Model"
"model": "Model",
"useCustom": "Use \"{{query}}\""
},
"crashScreen": {
"title": "Something went wrong",
Expand Down
Loading
Loading