Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
800 lines
25 KiB
TypeScript
800 lines
25 KiB
TypeScript
import type { AgentGUIComposerSettingsVM } from "./agentGuiNodeTypes";
|
|
import {
|
|
collapseModelOptionsToLatest,
|
|
groupModelOptionsByVendor
|
|
} from "./modelFamilies";
|
|
import { effectiveDefaultModelOption } from "./effectiveDefaultModel";
|
|
|
|
// Labels for the composer settings menus. Lives here (next to the pure menu
|
|
// model) so the model + the presentational component share one source; the
|
|
// component file re-exports it for existing importers.
|
|
export type AgentComposerSettingsMenuLabels = {
|
|
modelLabel: string;
|
|
modelSelectionLabel: string;
|
|
modelContextWindowSuffix: string;
|
|
modelTooltipVersionLabel: string;
|
|
defaultModel: string;
|
|
loadingOptions: string;
|
|
optionsLoadFailed?: string;
|
|
retry?: string;
|
|
retryTooltip?: string;
|
|
inheritedUnavailable: string;
|
|
reasoningLabel: string;
|
|
reasoningDegreeLabel: string;
|
|
reasoningOptionDefault: string;
|
|
reasoningOptionMinimal: string;
|
|
reasoningOptionLow: string;
|
|
reasoningOptionMedium: string;
|
|
reasoningOptionHigh: string;
|
|
reasoningOptionXHigh: string;
|
|
reasoningOptionMax: string;
|
|
reasoningOptionUltra: string;
|
|
speedLabel: string;
|
|
speedSelectionLabel: string;
|
|
speedOptionStandard: string;
|
|
speedOptionStandardDescription: string;
|
|
speedOptionFast: string;
|
|
speedOptionFastDescription: string;
|
|
permissionLabel: string;
|
|
planModeLabel: string;
|
|
permissionModeReadOnly?: string;
|
|
permissionModeAuto?: string;
|
|
permissionModeFullAccess?: string;
|
|
modelDescriptions: {
|
|
frontierComplexCoding: string;
|
|
everydayCoding: string;
|
|
smallFastCostEfficient: string;
|
|
codingOptimized: string;
|
|
ultraFastCoding: string;
|
|
professionalLongRunning: string;
|
|
};
|
|
};
|
|
|
|
export interface ComposerMenuOption {
|
|
value: string;
|
|
label: string;
|
|
description?: string;
|
|
summary?: string[];
|
|
tooltip?: ComposerModelOptionTooltip;
|
|
}
|
|
|
|
export interface ComposerModelOptionTooltip {
|
|
title: string;
|
|
description?: string;
|
|
consumptionMultiplier?: string;
|
|
contextWindow?: string;
|
|
version?: string;
|
|
}
|
|
|
|
export interface ComposerMenuSection {
|
|
/** Whether this dimension is configurable and has options to show. */
|
|
show: boolean;
|
|
/** The currently selected value ("" when none). */
|
|
selectedValue: string;
|
|
/** Display label for the current value (for the section/submenu trigger). */
|
|
selectedLabel: string;
|
|
/** Options with display labels already resolved. */
|
|
options: ComposerMenuOption[];
|
|
}
|
|
|
|
export interface ComposerMenuOptionGroup {
|
|
/** Vendor heading; null for the leading ungrouped entries (Auto etc.). */
|
|
label: string | null;
|
|
options: ComposerMenuOption[];
|
|
}
|
|
|
|
/** UI-local menu state fed into the pure model (search + history chrome). */
|
|
export interface ComposerModelMenuLocalState {
|
|
/** Raw filter text typed into the menu's search input. */
|
|
searchQuery?: string;
|
|
/** Favorite model ids for the composer target (localStorage chrome). */
|
|
favoriteModelIds?: readonly string[];
|
|
/** Most-recent-first model ids picked for the target (localStorage chrome). */
|
|
recentModelIds?: readonly string[];
|
|
}
|
|
|
|
/** Render the model filter input once the list exceeds this option count. */
|
|
export const COMPOSER_MODEL_SEARCH_THRESHOLD = 8;
|
|
|
|
export interface ComposerModelMenuModel {
|
|
/** The trigger should be disabled / the menu not openable. */
|
|
disabled: boolean;
|
|
trigger: {
|
|
isFast: boolean;
|
|
modelLabel: string;
|
|
reasoningLabel: string;
|
|
combinedLabel: string;
|
|
/** Render the single combined label vs. model + reasoning separately. */
|
|
showCombined: boolean;
|
|
};
|
|
model: ComposerMenuSection & {
|
|
/**
|
|
* Vendor-grouped view of `options` (Claude / GPT / ...); empty
|
|
* when the provider does not collapse its model list, in which case the
|
|
* menu renders the flat `options`.
|
|
*/
|
|
groups: ComposerMenuOptionGroup[];
|
|
/** Bound model access plan for the menu header badge; null when none. */
|
|
plan: { id: string; name: string } | null;
|
|
/**
|
|
* Plan-bound targets carry the source plan name in each option's
|
|
* `description`; render it as inline secondary text instead of the
|
|
* model-tooltip treatment.
|
|
*/
|
|
optionDescriptionInline: boolean;
|
|
/** The list is long enough to render the filter input. */
|
|
searchEnabled: boolean;
|
|
/** Normalized applied filter ("" when none). */
|
|
searchQuery: string;
|
|
/** Favorites group shown above recents; deduped out of `options`. */
|
|
favoriteOptions: ComposerMenuOption[];
|
|
/** Recently used group (most recent first); deduped out of `options`. */
|
|
recentOptions: ComposerMenuOption[];
|
|
/** Every favorited value present in the list, for star toggle states. */
|
|
favoriteValues: string[];
|
|
};
|
|
reasoning: ComposerMenuSection;
|
|
speed: ComposerMenuSection;
|
|
/**
|
|
* Active session with mid-session model switch support: the menu footer
|
|
* shows the "applies from the next request" hint.
|
|
*/
|
|
switchEffectHint: boolean;
|
|
}
|
|
|
|
/**
|
|
* Pure derivation of everything the model/reasoning/speed menu needs to render,
|
|
* from the composer view-model + labels. Keeping this free of React/radix makes
|
|
* the menu's behavior unit-testable and the presentational component thin, so a
|
|
* "nothing shows / nothing applies" bug is localizable to either this model or
|
|
* the small render that consumes it.
|
|
*/
|
|
export function buildComposerModelMenuModel(
|
|
composerSettings: AgentGUIComposerSettingsVM,
|
|
labels: AgentComposerSettingsMenuLabels,
|
|
localState: ComposerModelMenuLocalState = {}
|
|
): ComposerModelMenuModel {
|
|
const modelItems = modelOptionsWithSelectedValue(composerSettings);
|
|
const reasoningItems = reasoningOptionsWithSelectedValue(composerSettings);
|
|
const speedItems = speedOptionsWithSelectedValue(composerSettings);
|
|
|
|
const showModel =
|
|
composerSettings.supportsModel &&
|
|
modelItems.length > 0 &&
|
|
!composerSettings.modelUnavailable;
|
|
const showReasoning =
|
|
composerSettings.supportsReasoningEffort &&
|
|
reasoningItems.length > 0 &&
|
|
!composerSettings.reasoningUnavailable;
|
|
const showSpeed =
|
|
composerSettings.supportsSpeed &&
|
|
speedItems.length > 0 &&
|
|
!composerSettings.speedUnavailable;
|
|
|
|
const selectedModelValue = selectedComposerModelValue(composerSettings) ?? "";
|
|
const selectedReasoningValue =
|
|
selectedComposerReasoningValue(composerSettings) ?? "";
|
|
const selectedSpeedValue = selectedComposerSpeedValue(composerSettings) ?? "";
|
|
|
|
const effectiveDefaultModel = effectiveDefaultModelOption(
|
|
composerSettings,
|
|
modelItems
|
|
);
|
|
const modelLabel = resolveSelectedModelLabel(
|
|
composerSettings,
|
|
labels,
|
|
effectiveDefaultModel
|
|
);
|
|
// Only surface an effort label when the reasoning control is actually shown.
|
|
// Providers such as Cursor keep a stale/default draft effort ("high") even
|
|
// though reasoning is not configurable; showing it next to the model name
|
|
// reads like a real selection the user cannot change.
|
|
const reasoningLabel = showReasoning
|
|
? resolveSelectedReasoningLabel(composerSettings, labels)
|
|
: "";
|
|
|
|
const disabled =
|
|
composerSettings.isSettingsLoading ||
|
|
(!showModel && !showReasoning && !showSpeed);
|
|
|
|
return {
|
|
disabled,
|
|
trigger: {
|
|
isFast: selectedSpeedValue === "fast",
|
|
modelLabel,
|
|
reasoningLabel,
|
|
combinedLabel:
|
|
modelLabel === reasoningLabel
|
|
? modelLabel
|
|
: `${modelLabel} ${reasoningLabel}`.trim(),
|
|
showCombined: modelLabel === reasoningLabel || reasoningLabel.length === 0
|
|
},
|
|
model: (() => {
|
|
const allOptions = modelItems.map((option) =>
|
|
modelMenuOptionFromSettingOption(option, labels, effectiveDefaultModel)
|
|
);
|
|
const searchEnabled = allOptions.length > COMPOSER_MODEL_SEARCH_THRESHOLD;
|
|
const searchQuery = searchEnabled
|
|
? (localState.searchQuery ?? "").trim()
|
|
: "";
|
|
const visibleOptions = searchQuery
|
|
? filterComposerModelMenuOptions(allOptions, searchQuery)
|
|
: allOptions;
|
|
const favoriteValueSet = new Set(
|
|
(localState.favoriteModelIds ?? [])
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
);
|
|
const favoriteValues = allOptions
|
|
.map((option) => option.value)
|
|
.filter((value) => favoriteValueSet.has(value));
|
|
const favoriteOptions = visibleOptions.filter((option) =>
|
|
favoriteValueSet.has(option.value)
|
|
);
|
|
const visibleOptionsByValue = new Map(
|
|
visibleOptions.map((option) => [option.value, option])
|
|
);
|
|
const recentOptions = (localState.recentModelIds ?? [])
|
|
.map((value) => visibleOptionsByValue.get(value.trim()))
|
|
.filter(
|
|
(option): option is ComposerMenuOption =>
|
|
option !== undefined && !favoriteValueSet.has(option.value)
|
|
);
|
|
const pinnedValues = new Set([
|
|
...favoriteOptions.map((option) => option.value),
|
|
...recentOptions.map((option) => option.value)
|
|
]);
|
|
const options = visibleOptions.filter(
|
|
(option) => !pinnedValues.has(option.value)
|
|
);
|
|
return {
|
|
show: showModel,
|
|
selectedValue: selectedModelValue,
|
|
selectedLabel: modelLabel,
|
|
options,
|
|
groups:
|
|
showModel && composerSettings.collapseModelOptionsToLatest
|
|
? groupModelOptionsByVendor(options).filter(
|
|
(group) => group.options.length > 0
|
|
)
|
|
: [],
|
|
plan: composerSettings.modelPlan
|
|
? {
|
|
id: composerSettings.modelPlan.id,
|
|
name: composerSettings.modelPlan.name
|
|
}
|
|
: null,
|
|
optionDescriptionInline: Boolean(composerSettings.modelPlan),
|
|
searchEnabled,
|
|
searchQuery,
|
|
favoriteOptions,
|
|
recentOptions,
|
|
favoriteValues
|
|
};
|
|
})(),
|
|
reasoning: {
|
|
show: showReasoning,
|
|
selectedValue: selectedReasoningValue,
|
|
selectedLabel: reasoningLabel,
|
|
options: reasoningItems.map((option) => ({
|
|
value: option.value,
|
|
label: resolveReasoningOptionLabel(option.value, labels, option.label)
|
|
}))
|
|
},
|
|
speed: {
|
|
show: showSpeed,
|
|
selectedValue: selectedSpeedValue,
|
|
selectedLabel: resolveSpeedOptionLabel(selectedSpeedValue, labels),
|
|
options: speedItems.map((option) => ({
|
|
value: option.value,
|
|
label: resolveSpeedOptionLabel(option.value, labels),
|
|
description:
|
|
resolveSpeedOptionDescription(option.value, labels) ??
|
|
option.description
|
|
}))
|
|
},
|
|
switchEffectHint:
|
|
showModel && composerSettings.modelSwitchTakesEffectNextTurn === true
|
|
};
|
|
}
|
|
|
|
/** Case-insensitive filter over model label and id (value). */
|
|
export function filterComposerModelMenuOptions(
|
|
options: readonly ComposerMenuOption[],
|
|
searchQuery: string
|
|
): ComposerMenuOption[] {
|
|
const query = searchQuery.trim().toLowerCase();
|
|
if (!query) {
|
|
return [...options];
|
|
}
|
|
return options.filter(
|
|
(option) =>
|
|
option.label.toLowerCase().includes(query) ||
|
|
option.value.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
function modelMenuOptionFromSettingOption(
|
|
option: AgentGUIComposerSettingsVM["availableModels"][number],
|
|
labels: AgentComposerSettingsMenuLabels,
|
|
effectiveDefaultModel:
|
|
| AgentGUIComposerSettingsVM["availableModels"][number]
|
|
| null
|
|
): ComposerMenuOption {
|
|
const resolvedOption =
|
|
option.value === "default" ? effectiveDefaultModel : null;
|
|
const defaultLabel = shortModelDisplayLabel(labels.defaultModel);
|
|
const resolvedLabel = resolvedOption
|
|
? shortModelDisplayLabel(resolvedOption.label)
|
|
: "";
|
|
const displayLabel =
|
|
option.value === "default"
|
|
? resolvedLabel
|
|
? `${defaultLabel} (${resolvedLabel})`
|
|
: defaultLabel
|
|
: formatModelDisplayLabel(option.label);
|
|
const description = resolveModelDescription(
|
|
option.value === "default"
|
|
? resolvedOption?.description
|
|
: option.description,
|
|
labels
|
|
);
|
|
const consumptionMultiplier =
|
|
option.value === "default"
|
|
? resolvedOption?.consumptionMultiplier
|
|
: option.consumptionMultiplier;
|
|
const presentation = modelOptionPresentation({
|
|
consumptionMultiplier,
|
|
description,
|
|
label: displayLabel,
|
|
labels
|
|
});
|
|
return {
|
|
value: option.value,
|
|
label: option.value === "default" ? displayLabel : presentation.label,
|
|
...(description ? { description } : {}),
|
|
...(presentation.summary.length > 0
|
|
? { summary: presentation.summary }
|
|
: {}),
|
|
...(presentation.tooltip ? { tooltip: presentation.tooltip } : {})
|
|
};
|
|
}
|
|
|
|
function modelOptionPresentation(input: {
|
|
consumptionMultiplier: string | undefined;
|
|
description: string | undefined;
|
|
label: string;
|
|
labels: Pick<
|
|
AgentComposerSettingsMenuLabels,
|
|
| "modelContextWindowSuffix"
|
|
| "modelTooltipVersionLabel"
|
|
| "reasoningOptionDefault"
|
|
| "reasoningOptionMinimal"
|
|
| "reasoningOptionLow"
|
|
| "reasoningOptionMedium"
|
|
| "reasoningOptionHigh"
|
|
| "reasoningOptionXHigh"
|
|
| "reasoningOptionMax"
|
|
| "reasoningOptionUltra"
|
|
| "speedOptionFast"
|
|
>;
|
|
}): {
|
|
label: string;
|
|
summary: string[];
|
|
tooltip?: ComposerModelOptionTooltip;
|
|
} {
|
|
const description = input.description?.trim() || "";
|
|
const parsed = parseModelDescription(description);
|
|
const consumptionMultiplier = input.consumptionMultiplier?.trim() || "";
|
|
const label = shortModelDisplayLabel(input.label);
|
|
const summary = uniqueNonEmpty([
|
|
parsed.contextWindow?.summary,
|
|
parsed.effort
|
|
? reasoningSummaryLabel(parsed.effort.summaryValue, input.labels)
|
|
: null,
|
|
parsed.speed === "fast" ? input.labels.speedOptionFast : null
|
|
]);
|
|
const tooltipDescription =
|
|
parsed.body || (description && !parsed.title ? description : "");
|
|
const tooltip =
|
|
description || summary.length > 0 || consumptionMultiplier
|
|
? {
|
|
title: parsed.title ?? label,
|
|
...(tooltipDescription ? { description: tooltipDescription } : {}),
|
|
...(consumptionMultiplier
|
|
? { consumptionMultiplier: `${consumptionMultiplier}x` }
|
|
: {}),
|
|
...(parsed.contextWindow
|
|
? {
|
|
contextWindow: `${parsed.contextWindow.summary} ${input.labels.modelContextWindowSuffix}`
|
|
}
|
|
: {}),
|
|
...(parsed.effort
|
|
? {
|
|
version: `${input.labels.modelTooltipVersionLabel}: ${parsed.effort.version}`
|
|
}
|
|
: {})
|
|
}
|
|
: undefined;
|
|
return { label, summary, ...(tooltip ? { tooltip } : {}) };
|
|
}
|
|
|
|
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
for (const value of values) {
|
|
const normalized = value?.trim();
|
|
if (!normalized || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
result.push(normalized);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function reasoningSummaryLabel(
|
|
effort: string,
|
|
labels: Pick<
|
|
AgentComposerSettingsMenuLabels,
|
|
| "reasoningOptionDefault"
|
|
| "reasoningOptionMinimal"
|
|
| "reasoningOptionLow"
|
|
| "reasoningOptionMedium"
|
|
| "reasoningOptionHigh"
|
|
| "reasoningOptionXHigh"
|
|
| "reasoningOptionMax"
|
|
| "reasoningOptionUltra"
|
|
>
|
|
): string {
|
|
return resolveReasoningOptionLabel(effort, labels);
|
|
}
|
|
|
|
function parseModelDescription(description: string): {
|
|
body: string;
|
|
contextWindow?: { summary: string };
|
|
effort?: { summaryValue: string; version: string };
|
|
speed?: "fast";
|
|
title?: string;
|
|
} {
|
|
const parts = description
|
|
.split(/[·•\n]/)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
const firstPart = parts[0] ?? description.trim();
|
|
const contextWindow = contextWindowSummaryFromText(description);
|
|
const effort = effortFromText(description);
|
|
const speed = speedFromText(description);
|
|
const title = titleFromDescriptionPrefix(firstPart, contextWindow?.raw);
|
|
const bodyParts = (title ? parts.slice(1) : parts).filter(
|
|
(part) => !effortFromText(part)
|
|
);
|
|
return {
|
|
body: bodyParts.join(" · "),
|
|
...(contextWindow
|
|
? { contextWindow: { summary: contextWindow.summary } }
|
|
: {}),
|
|
...(effort ? { effort } : {}),
|
|
...(speed ? { speed } : {}),
|
|
...(title ? { title } : {})
|
|
};
|
|
}
|
|
|
|
function contextWindowSummaryFromText(
|
|
text: string
|
|
): { raw: string; summary: string } | null {
|
|
const match = text.match(
|
|
/\b(\d+(?:\.\d+)?\s*[kKmM])\s+(?:token\s+)?context(?:\s+window)?\b/
|
|
);
|
|
if (!match?.[1]) {
|
|
return null;
|
|
}
|
|
return {
|
|
raw: match[0],
|
|
summary: match[1].replace(/\s+/g, "").toUpperCase()
|
|
};
|
|
}
|
|
|
|
function titleFromDescriptionPrefix(
|
|
firstPart: string,
|
|
contextText: string | undefined
|
|
): string | undefined {
|
|
if (contextText) {
|
|
const contextIndex = firstPart
|
|
.toLowerCase()
|
|
.indexOf(contextText.toLowerCase());
|
|
if (contextIndex > 0) {
|
|
const title = firstPart
|
|
.slice(0, contextIndex)
|
|
.replace(/\bwith\s*$/i, "")
|
|
.trim();
|
|
return title ? formatModelDisplayLabel(title) : undefined;
|
|
}
|
|
}
|
|
if (/^[A-Za-z][A-Za-z0-9 ._-]*\d(?:[A-Za-z0-9 ._-]*)?$/.test(firstPart)) {
|
|
return formatModelDisplayLabel(firstPart);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function effortFromText(
|
|
text: string
|
|
): { summaryValue: string; version: string } | undefined {
|
|
const effortMatch = text
|
|
.toLowerCase()
|
|
.match(/\b(minimal|low|medium|high|x[-\s]?high|max|ultra)\s+effort\b/);
|
|
if (!effortMatch?.[1]) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
summaryValue: effortMatch[1].replace(/\s+/g, "").replace("-", ""),
|
|
version: effortMatch[0]
|
|
};
|
|
}
|
|
|
|
function speedFromText(text: string): "fast" | undefined {
|
|
return /\bfast\b/i.test(text) ? "fast" : undefined;
|
|
}
|
|
|
|
export function formatModelDisplayLabel(label: string): string {
|
|
const trimmed = label.trim();
|
|
if (!trimmed) {
|
|
return label;
|
|
}
|
|
const capitalized = trimmed.replace(
|
|
/(^|[^A-Za-z0-9])([A-Za-z])/g,
|
|
(_match, prefix: string, letter: string) =>
|
|
`${prefix}${letter.toUpperCase()}`
|
|
);
|
|
return capitalized.replace(/gpt/gi, "GPT");
|
|
}
|
|
|
|
function shortModelDisplayLabel(label: string): string {
|
|
const formatted = formatModelDisplayLabel(label.replace(/\([^)]*\)/g, ""));
|
|
const family = formatted.trim().split(/\s+/)[0];
|
|
switch (family?.toLowerCase()) {
|
|
case "default":
|
|
case "opus":
|
|
case "sonnet":
|
|
case "haiku":
|
|
return family;
|
|
default:
|
|
return formatted.trim() || label;
|
|
}
|
|
}
|
|
|
|
export function resolveModelDescription(
|
|
description: string | undefined,
|
|
labels: Pick<AgentComposerSettingsMenuLabels, "modelDescriptions">
|
|
): string | undefined {
|
|
switch (description) {
|
|
case "Frontier model for complex coding, research, and real-world work.":
|
|
return labels.modelDescriptions.frontierComplexCoding;
|
|
case "Strong model for everyday coding.":
|
|
return labels.modelDescriptions.everydayCoding;
|
|
case "Small, fast, and cost-efficient model for simpler coding tasks.":
|
|
return labels.modelDescriptions.smallFastCostEfficient;
|
|
case "Coding-optimized model.":
|
|
return labels.modelDescriptions.codingOptimized;
|
|
case "Ultra-fast coding model.":
|
|
return labels.modelDescriptions.ultraFastCoding;
|
|
case "Optimized for professional work and long-running agents.":
|
|
return labels.modelDescriptions.professionalLongRunning;
|
|
default:
|
|
return description;
|
|
}
|
|
}
|
|
|
|
function resolveSelectedModelLabel(
|
|
composerSettings: AgentGUIComposerSettingsVM,
|
|
labels: Pick<
|
|
AgentComposerSettingsMenuLabels,
|
|
"defaultModel" | "inheritedUnavailable" | "loadingOptions"
|
|
>,
|
|
effectiveDefaultModel:
|
|
| AgentGUIComposerSettingsVM["availableModels"][number]
|
|
| null
|
|
): string {
|
|
const selectedValue = selectedComposerModelValue(composerSettings);
|
|
const selected = modelOptionsWithSelectedValue(composerSettings).find(
|
|
(option) => option.value === selectedValue
|
|
);
|
|
if (selected) {
|
|
if (selected.value === "default") {
|
|
const defaultLabel = shortModelDisplayLabel(labels.defaultModel);
|
|
const effectiveLabel = effectiveDefaultModel
|
|
? shortModelDisplayLabel(effectiveDefaultModel.label)
|
|
: "";
|
|
return effectiveLabel
|
|
? `${defaultLabel} (${effectiveLabel})`
|
|
: defaultLabel;
|
|
}
|
|
return shortModelDisplayLabel(selected.label);
|
|
}
|
|
// While composer options load, show a clear loading placeholder rather than
|
|
// falling through to the "Default" label (which reads like a real choice).
|
|
if (
|
|
composerSettings.isSettingsLoading ||
|
|
composerSettings.isModelOptionsLoading === true
|
|
) {
|
|
return labels.loadingOptions;
|
|
}
|
|
if (composerSettings.modelUnavailable) {
|
|
return labels.inheritedUnavailable;
|
|
}
|
|
const firstAvailableModel = composerSettings.availableModels[0]?.label;
|
|
if (firstAvailableModel) {
|
|
return shortModelDisplayLabel(firstAvailableModel);
|
|
}
|
|
return labels.defaultModel;
|
|
}
|
|
|
|
function resolveSelectedReasoningLabel(
|
|
composerSettings: AgentGUIComposerSettingsVM,
|
|
labels: AgentComposerSettingsMenuLabels
|
|
): string {
|
|
const selectedValue = selectedComposerReasoningValue(composerSettings);
|
|
const selected = reasoningOptionsWithSelectedValue(composerSettings).find(
|
|
(option) => option.value === selectedValue
|
|
);
|
|
if (selected) {
|
|
return resolveReasoningOptionLabel(selected.value, labels, selected.label);
|
|
}
|
|
if (composerSettings.reasoningUnavailable) {
|
|
return labels.inheritedUnavailable;
|
|
}
|
|
if (composerSettings.isSettingsLoading) {
|
|
return "";
|
|
}
|
|
if (composerSettings.availableReasoningEfforts.length === 0) {
|
|
return "";
|
|
}
|
|
return labels.reasoningLabel;
|
|
}
|
|
|
|
export function resolveReasoningOptionLabel(
|
|
value: string,
|
|
labels: Pick<
|
|
AgentComposerSettingsMenuLabels,
|
|
| "reasoningOptionDefault"
|
|
| "reasoningOptionMinimal"
|
|
| "reasoningOptionLow"
|
|
| "reasoningOptionMedium"
|
|
| "reasoningOptionHigh"
|
|
| "reasoningOptionXHigh"
|
|
| "reasoningOptionMax"
|
|
| "reasoningOptionUltra"
|
|
>,
|
|
providerLabel?: string
|
|
): string {
|
|
switch (value) {
|
|
case "default":
|
|
return labels.reasoningOptionDefault;
|
|
case "minimal":
|
|
return labels.reasoningOptionMinimal;
|
|
case "low":
|
|
return labels.reasoningOptionLow;
|
|
case "medium":
|
|
return labels.reasoningOptionMedium;
|
|
case "high":
|
|
return labels.reasoningOptionHigh;
|
|
case "xhigh":
|
|
return labels.reasoningOptionXHigh;
|
|
case "max":
|
|
return labels.reasoningOptionMax;
|
|
case "ultra":
|
|
return labels.reasoningOptionUltra;
|
|
default:
|
|
return providerLabel?.trim() || value;
|
|
}
|
|
}
|
|
|
|
export function resolveSpeedOptionLabel(
|
|
value: string,
|
|
labels: Pick<
|
|
AgentComposerSettingsMenuLabels,
|
|
"speedOptionStandard" | "speedOptionFast"
|
|
>
|
|
): string {
|
|
switch (value) {
|
|
case "standard":
|
|
return labels.speedOptionStandard;
|
|
case "fast":
|
|
return labels.speedOptionFast;
|
|
default:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function resolveSpeedOptionDescription(
|
|
value: string,
|
|
labels: Pick<
|
|
AgentComposerSettingsMenuLabels,
|
|
"speedOptionStandardDescription" | "speedOptionFastDescription"
|
|
>
|
|
): string | undefined {
|
|
switch (value) {
|
|
case "standard":
|
|
return labels.speedOptionStandardDescription;
|
|
case "fast":
|
|
return labels.speedOptionFastDescription;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function selectedComposerModelValue(
|
|
composerSettings: AgentGUIComposerSettingsVM
|
|
): string | null {
|
|
return (
|
|
composerSettings.selectedModelValue ??
|
|
composerSettings.draftSettings.model ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function selectedComposerReasoningValue(
|
|
composerSettings: AgentGUIComposerSettingsVM
|
|
): string | null {
|
|
return (
|
|
composerSettings.selectedReasoningEffortValue ??
|
|
composerSettings.draftSettings.reasoningEffort ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function selectedComposerSpeedValue(
|
|
composerSettings: AgentGUIComposerSettingsVM
|
|
): string | null {
|
|
return (
|
|
composerSettings.selectedSpeedValue ??
|
|
composerSettings.draftSettings.speed ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function modelOptionsWithSelectedValue(
|
|
composerSettings: AgentGUIComposerSettingsVM
|
|
): AgentGUIComposerSettingsVM["availableModels"] {
|
|
// Collapse to the latest version per family first, then re-guarantee the
|
|
// selected value: a previously chosen older version stays visible (and
|
|
// selectable) even though its family collapsed to a newer release.
|
|
const models = composerSettings.collapseModelOptionsToLatest
|
|
? collapseModelOptionsToLatest(composerSettings.availableModels)
|
|
: composerSettings.availableModels;
|
|
return optionsWithSelectedValue(
|
|
models,
|
|
selectedComposerModelValue(composerSettings)
|
|
);
|
|
}
|
|
|
|
function reasoningOptionsWithSelectedValue(
|
|
composerSettings: AgentGUIComposerSettingsVM
|
|
): AgentGUIComposerSettingsVM["availableReasoningEfforts"] {
|
|
return optionsWithSelectedValue(
|
|
composerSettings.availableReasoningEfforts,
|
|
selectedComposerReasoningValue(composerSettings)
|
|
);
|
|
}
|
|
|
|
function speedOptionsWithSelectedValue(
|
|
composerSettings: AgentGUIComposerSettingsVM
|
|
): AgentGUIComposerSettingsVM["availableSpeeds"] {
|
|
return optionsWithSelectedValue(
|
|
composerSettings.availableSpeeds,
|
|
selectedComposerSpeedValue(composerSettings)
|
|
);
|
|
}
|
|
|
|
// Ensures the currently-selected value is always present as an option, even if
|
|
// the provider's advertised list does not include it (stale/custom values).
|
|
function optionsWithSelectedValue<T extends { value: string; label: string }>(
|
|
options: readonly T[],
|
|
selectedValue: string | null
|
|
): T[] {
|
|
if (!selectedValue || options.some((o) => o.value === selectedValue)) {
|
|
return [...options];
|
|
}
|
|
return [{ value: selectedValue, label: selectedValue } as T, ...options];
|
|
}
|