Skip to content
Closed
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
28 changes: 25 additions & 3 deletions apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,28 @@ import { useComposerMenuState } from "./useComposerMenuState";

type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;

function TraitsTriggerIcon({ level, size }: { level: number | null; size: ComposerControlSize }) {
if (level === null) return <ComposerControlIcon icon={GaugeIcon} size={size} />;
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className={cn("shrink-0", size === "xs" ? "size-3" : "size-4")}
data-composer-control-icon
>
<path d="M3.34 19a10 10 0 1 1 17.32 0" opacity={0.3} />
{level > 0 && (
<path d="M3.34 19a10 10 0 1 1 17.32 0" pathLength={1} strokeDasharray={`${level} 2`} />
)}
<path d="M12 14V8.34" transform={`rotate(${240 * level - 120} 12 14)`} />
</svg>
);
}

/**
* All the picker reads from the prompt while rendering. Passing this instead of
* the prompt keeps the picker from re-rendering on every keystroke.
Expand Down Expand Up @@ -555,7 +577,7 @@ export const TraitsPicker = memo(function TraitsPicker({
return null;
}

const { label: triggerLabel } = buildTraitsTriggerDisplay({
const { label: triggerLabel, reasoningLevel } = buildTraitsTriggerDisplay({
provider,
descriptors,
primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
Expand Down Expand Up @@ -600,15 +622,15 @@ export const TraitsPicker = memo(function TraitsPicker({
size === "xs" ? "gap-1" : "gap-1.5",
)}
>
<ComposerControlIcon icon={GaugeIcon} size={size} />
<TraitsTriggerIcon level={reasoningLevel} size={size} />
<span data-composer-control-label className="min-w-0 truncate">
{triggerLabel}
</span>
<ComposerControlChevron size={size} />
</span>
) : (
<>
<ComposerControlIcon icon={GaugeIcon} size={size} />
<TraitsTriggerIcon level={reasoningLevel} size={size} />
<span data-composer-control-label>{triggerLabel}</span>
<ComposerControlChevron size={size} />
</>
Expand Down
42 changes: 42 additions & 0 deletions packages/client-runtime/src/modelTraits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { buildTraitsTriggerDisplay } from "./modelTraits.ts";

function highEffortLevel(efforts: ReadonlyArray<string>, thinking?: boolean) {
const descriptors: Array<ProviderOptionDescriptor> = [
{
id: "effort",
label: "Reasoning",
type: "select",
options: efforts.map((id) => ({ id, label: id })),
currentValue: "high",
},
];
if (thinking !== undefined) {
descriptors.push({
id: "thinking",
label: "Thinking",
type: "boolean",
currentValue: thinking,
});
}
return buildTraitsTriggerDisplay({
provider: ProviderDriverKind.make("cursor"),
descriptors,
primarySelectDescriptorId: "effort",
ultrathinkPromptControlled: false,
}).reasoningLevel;
}

describe("buildTraitsTriggerDisplay reasoningLevel", () => {
it("reads High the same however the provider orders or trims its efforts", () => {
const claude = highEffortLevel(["low", "medium", "high", "xhigh", "max"]);
expect(claude).toBeGreaterThan(0);
expect(highEffortLevel(["xhigh", "high", "medium", "low"])).toBe(claude);
expect(highEffortLevel(["high", "max"])).toBe(claude);
});

it("reads zero when thinking is switched off", () => {
expect(highEffortLevel(["low", "high"], false)).toBe(0);
});
});
42 changes: 39 additions & 3 deletions packages/client-runtime/src/modelTraits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,17 @@ export function buildTraitsTriggerDisplay(input: {
ultrathinkPromptControlled: boolean;
modelSelection?: ModelSelection | null;
reportedModelSelection?: ModelSelection | null | undefined;
}): { label: string } {
}): { label: string; reasoningLevel: number | null } {
let fastModeFallbackLabel: string | null = null;
let speedLabel: string | null = null;
let reasoningLabelIndex = -1;
let reasoningLevel: number | null = null;
let thinkingOff = false;
const labels: Array<string> = [];
for (const descriptor of input.descriptors) {
if (descriptor.id === "thinking" && descriptor.type === "boolean") {
thinkingOff = descriptor.currentValue === false;
}
if (descriptor.id === "fastMode" && descriptor.type === "boolean") {
speedLabel = descriptor.currentValue === true ? "Fast" : null;
fastModeFallbackLabel = speedLabel ?? "Normal";
Expand Down Expand Up @@ -162,13 +167,21 @@ export function buildTraitsTriggerDisplay(input: {
REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id)
) {
reasoningLabelIndex = labels.length;
reasoningLevel = getReasoningLevel(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
getProviderOptionCurrentValue(
descriptor,
input.modelSelection,
input.reportedModelSelection,
),
);
}
labels.push(label);
}
}

if (thinkingOff) reasoningLevel = 0;
if (labels.length === 0 && fastModeFallbackLabel !== null) {
return { label: fastModeFallbackLabel };
return { label: fastModeFallbackLabel, reasoningLevel };
}
if (speedLabel) {
if (reasoningLabelIndex >= 0) {
Expand All @@ -177,5 +190,28 @@ export function buildTraitsTriggerDisplay(input: {
labels.push(speedLabel);
}
}
return { label: labels.join(" · ") };
return { label: labels.join(" · "), reasoningLevel };
}

// One scale for every provider so the same effort reads the same on any model.
// Ultrathink is absent: it leaves the effort sent to Claude unchanged.
const REASONING_LEVEL_RANKS: ReadonlyMap<string, number> = new Map([
["none", 0],
["off", 0],
["minimal", 1],
["low", 2],
["medium", 3],
["high", 4],
["xhigh", 5],
// Claude runs Ultracode at xhigh effort.
["ultracode", 5],
["max", 6],
["ultra", 7],
]);
const MAX_REASONING_LEVEL_RANK = 7;

function getReasoningLevel(value: string | boolean | undefined): number | null {
const rank =
typeof value === "string" ? REASONING_LEVEL_RANKS.get(value.toLowerCase()) : undefined;
return rank === undefined ? null : rank / MAX_REASONING_LEVEL_RANK;
}
Loading