1import {
2 type ModelSelection,
3 type ProviderDriverKind,
4 type ProviderInstanceId,
5 type ProviderOptionDescriptor,
6 type ProviderOptionSelection,
7 type ScopedThreadRef,
8 type ServerProviderModel,
9} from "@t3tools/contracts";
10import {
11 applyClaudePromptEffortPrefix,
12 buildProviderOptionSelectionsFromDescriptors,
13 getProviderOptionCurrentLabel,
14 getProviderOptionCurrentValue,
15 getProviderOptionDescriptors,
16 isClaudeUltrathinkPrompt,
17 normalizeModelSlug,
18} from "@t3tools/shared/model";
19import { memo, useCallback } from "react";
20import { BrainIcon } from "lucide-react";
21import {
22 Menu,
23 MenuGroup,
24 MenuPopup,
25 MenuRadioGroup,
26 MenuRadioItem,
27 MenuSeparator as MenuDivider,
28 MenuTrigger,
29} from "../ui/menu";
30import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
31import { getProviderModelCapabilities } from "../../providerModels";
32import { cn } from "~/lib/utils";
33import { Badge } from "../ui/badge";
34import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
35import {
36 ComposerControl,
37 ComposerControlChevron,
38 ComposerControlIcon,
39 type ComposerControlSize,
40} from "./ComposerControl";
41import { useComposerMenuProps } from "./composerEventScope";
42import { useComposerMenuState } from "./useComposerMenuState";
43
44type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
45
46const SAVED_OPTION_LABELS: Readonly<Record<string, string>> = {
47 agent: "Agent",
48 effort: "Effort",
49 reasoningEffort: "Reasoning effort",
50 variant: "Reasoning",
51};
52
53function savedOptionLabel(id: string): string {
54 return (
55 SAVED_OPTION_LABELS[id] ??
56 id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (character) => character.toUpperCase())
57 );
58}
59
60/** Read-only descriptors for saved values whose OpenCode model metadata is unavailable. */
61export function buildUnavailableModelOptionDescriptors(
62 selections: ProviderOptions | null | undefined,
63): ReadonlyArray<ProviderOptionDescriptor> {
64 return (selections ?? []).map((selection) =>
65 typeof selection.value === "boolean"
66 ? {
67 id: selection.id,
68 label: savedOptionLabel(selection.id),
69 type: "boolean" as const,
70 currentValue: selection.value,
71 }
72 : {
73 id: selection.id,
74 label: savedOptionLabel(selection.id),
75 type: "select" as const,
76 options: [{ id: selection.value, label: selection.value }],
77 currentValue: selection.value,
78 },
79 );
80}
81
82type TraitsPersistence =
83 | {
84 threadRef?: ScopedThreadRef;
85 draftId?: DraftId;
86 onModelOptionsChange?: never;
87 }
88 | {
89 threadRef?: undefined;
90 onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
91 };
92
93const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
94
95function DefaultBadge() {
96 return (
97 <Badge variant="outline" size="sm" className="min-w-0">
98 Default
99 </Badge>
100 );
101}
102
103function replaceDescriptorCurrentValue(
104 descriptors: ReadonlyArray<ProviderOptionDescriptor>,
105 descriptorId: string,
106 currentValue: string | boolean | undefined,
107): ReadonlyArray<ProviderOptionDescriptor> {
108 return descriptors.map((descriptor) =>
109 descriptor.id !== descriptorId
110 ? descriptor
111 : descriptor.type === "boolean"
112 ? {
113 ...descriptor,
114 ...(typeof currentValue === "boolean" ? { currentValue } : {}),
115 }
116 : {
117 ...descriptor,
118 ...(typeof currentValue === "string" ? { currentValue } : {}),
119 },
120 );
121}
122
123function getDescriptorStringValue(
124 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null,
125 selection?: ModelSelection | null,
126 reportedSelection?: ModelSelection | null,
127): string | null {
128 if (!descriptor) {
129 return null;
130 }
131 const value = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
132 return typeof value === "string" ? value : null;
133}
134
135function getSelectedTraits(
136 provider: ProviderDriverKind,
137 models: ReadonlyArray<ServerProviderModel>,
138 model: string | null | undefined,
139 prompt: string,
140 modelOptions: ProviderOptions | null | undefined,
141 allowPromptInjectedEffort: boolean,
142 planModeEnabled: boolean,
143) {
144 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
145 const modelIsUnavailable =
146 provider === "opencode" &&
147 !models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider));
148 const descriptors = modelIsUnavailable
149 ? buildUnavailableModelOptionDescriptors(
150 planModeEnabled
151 ? modelOptions
152 : modelOptions?.filter((option) => option.id !== "agent" || option.value !== "plan"),
153 )
154 : getProviderOptionDescriptors({
155 caps,
156 selections: modelOptions,
157 });
158 const selectDescriptors = descriptors.filter(
159 (descriptor): descriptor is Extract<ProviderOptionDescriptor, { type: "select" }> =>
160 descriptor.type === "select",
161 );
162 const booleanDescriptors = descriptors.filter(
163 (descriptor): descriptor is Extract<ProviderOptionDescriptor, { type: "boolean" }> =>
164 descriptor.type === "boolean",
165 );
166 const primarySelectDescriptor = selectDescriptors[0] ?? null;
167 const contextWindowDescriptor =
168 selectDescriptors.find((descriptor) => descriptor.id === "contextWindow") ?? null;
169 const agentDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "agent") ?? null;
170 const fastModeDescriptor =
171 booleanDescriptors.find((descriptor) => descriptor.id === "fastMode") ?? null;
172 const thinkingDescriptor =
173 booleanDescriptors.find((descriptor) => descriptor.id === "thinking") ?? null;
174
175 // Prompt-controlled effort (e.g. ultrathink in prompt text)
176 const ultrathinkPromptControlled =
177 allowPromptInjectedEffort &&
178 (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 &&
179 isClaudeUltrathinkPrompt(prompt);
180
181 // Check if "ultrathink" appears in the body text (not just our prefix)
182 const ultrathinkInBodyText =
183 ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, ""));
184 const effort =
185 (ultrathinkPromptControlled
186 ? "ultrathink"
187 : getDescriptorStringValue(primarySelectDescriptor)) ?? null;
188 const thinkingEnabled =
189 typeof thinkingDescriptor?.currentValue === "boolean" ? thinkingDescriptor.currentValue : null;
190 const contextWindow = getDescriptorStringValue(contextWindowDescriptor);
191 const selectedAgent = getDescriptorStringValue(agentDescriptor);
192 const selectedAgentLabel = agentDescriptor
193 ? getProviderOptionCurrentLabel(agentDescriptor)
194 : null;
195
196 return {
197 caps,
198 descriptors,
199 selectDescriptors,
200 booleanDescriptors,
201 primarySelectDescriptor,
202 contextWindowDescriptor,
203 agentDescriptor,
204 fastModeDescriptor,
205 thinkingDescriptor,
206 effort,
207 thinkingEnabled,
208 contextWindow,
209 ultrathinkPromptControlled,
210 ultrathinkInBodyText,
211 selectedAgent,
212 selectedAgentLabel,
213 modelIsUnavailable,
214 };
215}
216
217function getTraitsSectionVisibility(input: {
218 provider: ProviderDriverKind;
219 models: ReadonlyArray<ServerProviderModel>;
220 model: string | null | undefined;
221 prompt: string;
222 modelOptions: ProviderOptions | null | undefined;
223 allowPromptInjectedEffort?: boolean;
224 planModeEnabled: boolean;
225}) {
226 const selected = getSelectedTraits(
227 input.provider,
228 input.models,
229 input.model,
230 input.prompt,
231 input.modelOptions,
232 input.allowPromptInjectedEffort ?? true,
233 input.planModeEnabled,
234 );
235
236 const showEffort = selected.primarySelectDescriptor !== null;
237 const showThinking = selected.thinkingDescriptor !== null;
238 const showFastMode = selected.fastModeDescriptor !== null;
239 const showContextWindow = selected.contextWindowDescriptor !== null;
240 const showAgent = selected.agentDescriptor !== null;
241
242 return {
243 ...selected,
244 showEffort,
245 showThinking,
246 showFastMode,
247 showContextWindow,
248 showAgent,
249 hasAnyControls:
250 showEffort ||
251 showThinking ||
252 showFastMode ||
253 showContextWindow ||
254 showAgent ||
255 (selected.modelIsUnavailable && selected.descriptors.length > 0),
256 };
257}
258
259export function shouldRenderTraitsControls(input: {
260 provider: ProviderDriverKind;
261 models: ReadonlyArray<ServerProviderModel>;
262 model: string | null | undefined;
263 prompt: string;
264 modelOptions: ProviderOptions | null | undefined;
265 allowPromptInjectedEffort?: boolean;
266 planModeEnabled: boolean;
267}): boolean {
268 return getTraitsSectionVisibility(input).hasAnyControls;
269}
270
271export interface TraitsMenuContentProps {
272 provider: ProviderDriverKind;
273 instanceId?: ProviderInstanceId;
274 models: ReadonlyArray<ServerProviderModel>;
275 model: string | null | undefined;
276 prompt: string;
277 onPromptChange: (prompt: string) => void;
278 modelOptions?: ProviderOptions | null | undefined;
279 reportedModelSelection?: ModelSelection | null | undefined;
280 allowPromptInjectedEffort?: boolean;
281 planModeEnabled: boolean;
282 triggerClassName?: string;
283 isComposerOwned?: boolean;
284}
285
286export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
287 provider,
288 instanceId,
289 models,
290 model,
291 prompt,
292 onPromptChange,
293 modelOptions,
294 reportedModelSelection,
295 allowPromptInjectedEffort = true,
296 planModeEnabled,
297 ...persistence
298}: TraitsMenuContentProps & TraitsPersistence) {
299 const modelSelection =
300 instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null;
301 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
302 const updateModelOptions = useCallback(
303 (nextOptions: ProviderOptions | undefined) => {
304 if ("onModelOptionsChange" in persistence) {
305 persistence.onModelOptionsChange(nextOptions);
306 return;
307 }
308 const threadTarget = persistence.threadRef ?? persistence.draftId;
309 if (!threadTarget) {
310 return;
311 }
312 setProviderModelOptions(threadTarget, provider, nextOptions, {
313 ...(instanceId ? { instanceId } : {}),
314 model,
315 persistSticky: true,
316 });
317 },
318 [instanceId, model, persistence, provider, setProviderModelOptions],
319 );
320 const {
321 descriptors,
322 selectDescriptors,
323 booleanDescriptors,
324 primarySelectDescriptor,
325 ultrathinkPromptControlled,
326 ultrathinkInBodyText,
327 hasAnyControls,
328 modelIsUnavailable,
329 } = getTraitsSectionVisibility({
330 provider,
331 models,
332 model,
333 prompt,
334 modelOptions,
335 allowPromptInjectedEffort,
336 planModeEnabled,
337 });
338 const updateDescriptors = (nextDescriptors: ReadonlyArray<ProviderOptionDescriptor>) => {
339 updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors));
340 };
341
342 const handleSelectChange = (
343 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
344 value: string,
345 ) => {
346 if (!value) return;
347 if (descriptor.promptInjectedValues?.includes(value)) {
348 const nextPrompt =
349 prompt.trim().length === 0
350 ? ULTRATHINK_PROMPT_PREFIX
351 : applyClaudePromptEffortPrefix(prompt, "ultrathink");
352 onPromptChange(nextPrompt);
353 return;
354 }
355 if (ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id) return;
356 if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) {
357 const stripped = prompt.replace(/^Ultrathink:\s*/i, "");
358 onPromptChange(stripped);
359 }
360 updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value));
361 };
362
363 if (!hasAnyControls) {
364 return null;
365 }
366
367 if (modelIsUnavailable) {
368 return (
369 <>
370 {descriptors.map((descriptor, index) => {
371 const value = getProviderOptionCurrentLabel(
372 descriptor,
373 modelSelection,
374 reportedModelSelection,
375 );
376 if (!value) return null;
377 return (
378 <div key={descriptor.id}>
379 {index > 0 ? <MenuDivider /> : null}
380 <MenuGroup>
381 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
382 {descriptor.label}
383 </div>
384 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">{value}</div>
385 </MenuGroup>
386 </div>
387 );
388 })}
389 </>
390 );
391 }
392
393 return (
394 <>
395 {selectDescriptors.map((descriptor, index) => {
396 const selectedValue =
397 ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id
398 ? "ultrathink"
399 : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? "");
400
401 return (
402 <div key={descriptor.id}>
403 {index > 0 ? <MenuDivider /> : null}
404 <MenuGroup>
405 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
406 {descriptor.label}
407 </div>
408 {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? (
409 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">
410 Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change this
411 option.
412 </div>
413 ) : null}
414 <MenuRadioGroup
415 value={selectedValue}
416 onValueChange={(value) => handleSelectChange(descriptor, value)}
417 >
418 {descriptor.options.map((option) => (
419 <MenuRadioItem
420 key={option.id}
421 value={option.id}
422 hideIndicator
423 // Base UI keeps radio menus open by default. Close on pick so
424 // the traits menu behaves like the model picker.
425 closeOnClick
426 disabled={ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id}
427 >
428 <span className="flex w-full min-w-0 flex-col">
429 <span className="flex w-full min-w-0 items-center justify-between gap-3">
430 <span className="min-w-0 truncate">
431 {option.label}
432 {option.isDefault ? (
433 <>
434 {" "}
435 <DefaultBadge />
436 </>
437 ) : null}
438 </span>
439 </span>
440 {option.description ? (
441 <span className="max-w-56 text-pretty text-muted-foreground/80 text-xs">
442 {option.description}
443 </span>
444 ) : null}
445 </span>
446 </MenuRadioItem>
447 ))}
448 </MenuRadioGroup>
449 </MenuGroup>
450 </div>
451 );
452 })}
453 {booleanDescriptors.map((descriptor, index) => {
454 const selectedValue = descriptor.currentValue === true ? "on" : "off";
455
456 return (
457 <div key={descriptor.id}>
458 {index > 0 || selectDescriptors.length > 0 ? <MenuDivider /> : null}
459 <MenuGroup>
460 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
461 {descriptor.label}
462 </div>
463 <MenuRadioGroup
464 value={selectedValue}
465 onValueChange={(value) => {
466 updateDescriptors(
467 replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"),
468 );
469 }}
470 >
471 {(["on", "off"] as const).map((value) => (
472 <MenuRadioItem key={value} value={value} hideIndicator closeOnClick>
473 <span className="flex w-full min-w-0 items-center justify-between gap-3">
474 <span>{value === "on" ? "On" : "Off"}</span>
475 </span>
476 </MenuRadioItem>
477 ))}
478 </MenuRadioGroup>
479 </MenuGroup>
480 </div>
481 );
482 })}
483 </>
484 );
485});
486
487/** Pair speed with reasoning while keeping other traits separated. */
488export function buildTraitsTriggerDisplay(input: {
489 provider: ProviderDriverKind;
490 descriptors: ReadonlyArray<ProviderOptionDescriptor>;
491 primarySelectDescriptorId: string | null;
492 ultrathinkPromptControlled: boolean;
493 modelSelection?: ModelSelection | null;
494 reportedModelSelection?: ModelSelection | null | undefined;
495}): { label: string } {
496 let fastModeFallbackLabel: string | null = null;
497 let speedLabel: string | null = null;
498 let reasoningLabelIndex = -1;
499 const labels: Array<string> = [];
500 for (const descriptor of input.descriptors) {
501 if (descriptor.id === "fastMode" && descriptor.type === "boolean") {
502 speedLabel = descriptor.currentValue === true ? "Fast" : null;
503 fastModeFallbackLabel = speedLabel ?? "Normal";
504 continue;
505 }
506 if (
507 input.provider === "codex" &&
508 descriptor.id === "serviceTier" &&
509 descriptor.type === "select"
510 ) {
511 const currentValue = getProviderOptionCurrentValue(descriptor);
512 const fastTier = descriptor.options.find(({ label }) => label === "Fast");
513 const ultrafastTier = descriptor.options.find(({ label }) => label === "Ultrafast");
514 if (
515 ((fastTier || ultrafastTier) && currentValue === "default") ||
516 (fastTier && currentValue === fastTier.id) ||
517 (ultrafastTier && currentValue === ultrafastTier.id)
518 ) {
519 speedLabel =
520 ultrafastTier && currentValue === ultrafastTier.id
521 ? "Ultrafast"
522 : fastTier && currentValue === fastTier.id
523 ? "Fast"
524 : null;
525 fastModeFallbackLabel =
526 descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal";
527 continue;
528 }
529 }
530 const label =
531 input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId
532 ? "Ultrathink"
533 : descriptor.type === "boolean"
534 ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`
535 : getProviderOptionCurrentLabel(
536 descriptor,
537 input.modelSelection,
538 input.reportedModelSelection,
539 );
540 if (typeof label === "string" && label.length > 0) {
541 // Custom models retain descriptor order, so the primary select can be context.
542 if (
543 reasoningLabelIndex === -1 &&
544 descriptor.type === "select" &&
545 ["reasoningEffort", "reasoning", "effort", "variant", "thinking"].includes(descriptor.id)
546 ) {
547 reasoningLabelIndex = labels.length;
548 }
549 labels.push(label);
550 }
551 }
552
553 // Only fall back to text when fast mode is genuinely the sole trait. Keying
554 // off an empty label list alone would also catch descriptors that resolved to
555 // no label at all, printing a bogus "Normal" for a model without fast mode.
556 if (labels.length === 0 && fastModeFallbackLabel !== null) {
557 return { label: fastModeFallbackLabel };
558 }
559 if (speedLabel) {
560 if (reasoningLabelIndex >= 0) {
561 labels[reasoningLabelIndex] = `${labels[reasoningLabelIndex]} ${speedLabel}`;
562 } else {
563 labels.push(speedLabel);
564 }
565 }
566 return { label: labels.join(" · ") };
567}
568
569export const TraitsPicker = memo(function TraitsPicker({
570 provider,
571 instanceId,
572 models,
573 model,
574 prompt,
575 onPromptChange,
576 modelOptions,
577 reportedModelSelection,
578 allowPromptInjectedEffort = true,
579 planModeEnabled,
580 triggerClassName,
581 isComposerOwned,
582 size = "sm",
583 hidden = false,
584 disabled = false,
585 ...persistence
586}: TraitsMenuContentProps &
587 TraitsPersistence & {
588 size?: ComposerControlSize;
589 hidden?: boolean;
590 disabled?: boolean;
591 }) {
592 const composerFloatingLayerProps = useComposerMenuProps();
593 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden || disabled);
594 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
595 getTraitsSectionVisibility({
596 provider,
597 models,
598 model,
599 prompt,
600 modelOptions,
601 allowPromptInjectedEffort,
602 planModeEnabled,
603 });
604 if (
605 !shouldRenderTraitsControls({
606 provider,
607 models,
608 model,
609 prompt,
610 modelOptions,
611 allowPromptInjectedEffort,
612 planModeEnabled,
613 })
614 ) {
615 return null;
616 }
617
618 const { label: triggerLabel } = buildTraitsTriggerDisplay({
619 provider,
620 descriptors,
621 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
622 ultrathinkPromptControlled,
623 modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null,
624 reportedModelSelection,
625 });
626 const isCodexStyle = provider === "codex";
627
628 return (
629 <Menu
630 open={isMenuOpen}
631 onOpenChange={(open) => {
632 setIsMenuOpen(open && !disabled);
633 }}
634 >
635 <Tooltip>
636 <TooltipTrigger
637 render={
638 <MenuTrigger
639 render={
640 <ComposerControl
641 disabled={disabled}
642 aria-label={triggerLabel}
643 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
644 size={size}
645 className={cn(
646 isCodexStyle
647 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"
648 : "shrink-0 whitespace-nowrap",
649 triggerClassName,
650 )}
651 />
652 }
653 />
654 }
655 >
656 {isCodexStyle ? (
657 // The label truncates itself; clipping the wrapper too would cut off
658 // the chevron, whose negative end margin overhangs the wrapper edge.
659 <span
660 className={cn(
661 "flex min-w-0 w-full items-center",
662 size === "xs" ? "gap-1" : "gap-1.5",
663 )}
664 >
665 <ComposerControlIcon icon={BrainIcon} size={size} />
666 <span data-composer-control-label className="min-w-0 truncate">
667 {triggerLabel}
668 </span>
669 <ComposerControlChevron size={size} />
670 </span>
671 ) : (
672 <>
673 <ComposerControlIcon icon={BrainIcon} size={size} />
674 <span data-composer-control-label>{triggerLabel}</span>
675 <ComposerControlChevron size={size} />
676 </>
677 )}
678 </TooltipTrigger>
679 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
680 </Tooltip>
681 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
682 <TraitsMenuContent
683 provider={provider}
684 {...(instanceId ? { instanceId } : {})}
685 models={models}
686 model={model}
687 prompt={prompt}
688 onPromptChange={onPromptChange}
689 modelOptions={modelOptions}
690 reportedModelSelection={reportedModelSelection}
691 allowPromptInjectedEffort={allowPromptInjectedEffort}
692 planModeEnabled={planModeEnabled}
693 {...persistence}
694 />
695 </MenuPopup>
696 </Menu>
697 );
698});