diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 37038e241154..62b9c655e634 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6226,6 +6226,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "reasoning.increase" || command === "reasoning.decrease") { + event.preventDefault(); + event.stopPropagation(); + composerRef.current?.adjustReasoningLevel(command === "reasoning.increase" ? 1 : -1); + return; + } + if (command === "thread.stop") { // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. if (!canInterruptRunningThread) return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bff16c88bbcc..f64599a8fc60 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -177,6 +177,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { getReasoningLevelChange } from "./TraitsPicker"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; @@ -1185,6 +1186,7 @@ export interface ChatComposerHandle { openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; + adjustReasoningLevel: (direction: 1 | -1) => void; compactContext: () => void; readSnapshot: () => { value: string; @@ -4738,6 +4740,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, ), openModelPicker, + adjustReasoningLevel: (direction) => { + if (noProviderAvailable || isComposerApprovalState || pendingUserInputs.length > 0) return; + const change = getReasoningLevelChange({ + provider: selectedProvider, + models: selectedProviderModels, + model: selectedModel, + modelOptions: composerModelOptions?.[selectedInstanceId], + prompt: promptRef.current, + planModeEnabled: settings.planModeEnabled, + direction, + }); + if (!change) return; + if ("modelOptions" in change) { + useComposerDraftStore + .getState() + .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }); + } + if (change.prompt !== promptRef.current) setPromptFromTraits(change.prompt); + }, toggleModelPicker: () => { if (isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); @@ -4836,6 +4861,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThread, addComposerAttachments, composerDraftTarget, + composerModelOptions, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, @@ -4858,6 +4884,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerModelPickerOpen, openModelPicker, readComposerSnapshot, + selectedInstanceId, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, @@ -4866,6 +4893,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedProvider, selectedProviderModels, + setPromptFromTraits, + settings.planModeEnabled, interactionMode, planModeUiEnabled, compactThreadContext, diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index dbe97cdd54e0..5f3b34a1b734 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; -import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; +import { + ProviderDriverKind, + type ProviderOptionDescriptor, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { + getReasoningLevelChange, + buildTraitsTriggerDisplay, + buildUnavailableModelOptionDescriptors, +} from "./TraitsPicker"; function selectDescriptor( id: string, @@ -188,3 +196,106 @@ describe("buildUnavailableModelOptionDescriptors", () => { ]); }); }); + +describe("reasoning level shortcuts", () => { + const modelWith = ( + descriptors: ReadonlyArray, + ): ReadonlyArray => [ + { + slug: "test-model", + name: "Test", + isCustom: false, + capabilities: { optionDescriptors: descriptors }, + }, + ]; + const options = [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ]; + const input = { + provider: CODEX, + model: "test-model", + models: modelWith([ + { ...selectDescriptor("effort", options, "high"), promptInjectedValues: ["ultrathink"] }, + fastModeDescriptor(false), + ]), + modelOptions: [{ id: "fastMode", value: true }], + prompt: "Solve this", + planModeEnabled: false, + direction: -1 as const, + }; + + it("steps from the default, skips unsupported levels, and preserves other traits", () => { + expect( + getReasoningLevelChange({ + ...input, + models: modelWith([ + { id: "effort", label: "Effort", type: "select", options }, + fastModeDescriptor(false), + ]), + }), + ).toEqual({ + prompt: "Solve this", + modelOptions: [ + { id: "effort", value: "low" }, + { id: "fastMode", value: true }, + ], + }); + }); + + it("stops at both ends", () => { + expect( + getReasoningLevelChange({ ...input, modelOptions: [{ id: "effort", value: "low" }] }), + ).toBeNull(); + expect( + getReasoningLevelChange({ ...input, prompt: "Ultrathink:\nSolve this", direction: 1 }), + ).toBeNull(); + }); + + it("enters and leaves prompt-controlled ultrathink", () => { + const increased = getReasoningLevelChange({ ...input, direction: 1 }); + expect(increased).toEqual({ prompt: "Ultrathink:\nSolve this" }); + expect(getReasoningLevelChange({ ...input, prompt: increased!.prompt })).toEqual({ + prompt: "Solve this", + modelOptions: [ + { id: "effort", value: "high" }, + { id: "fastMode", value: true }, + ], + }); + expect( + getReasoningLevelChange({ ...input, prompt: "Please ultrathink about this" }), + ).toBeNull(); + }); + + it("changes only the prompt when enabling ultrathink, and preserves slash commands", () => { + expect(getReasoningLevelChange({ ...input, prompt: "", direction: 1 })).toEqual({ + prompt: "Ultrathink:\n", + }); + expect(getReasoningLevelChange({ ...input, prompt: "/review", direction: 1 })).toEqual({ + prompt: "/review", + }); + }); + + it.each(["reasoningEffort", "effort", "variant"])("supports the %s descriptor", (id) => { + expect( + getReasoningLevelChange({ + ...input, + models: modelWith([selectDescriptor(id, options, "high")]), + })?.modelOptions, + ).toEqual([{ id, value: "low" }]); + }); + + it.each(["agent", "contextWindow"])("does not step through %s options", (id) => { + expect( + getReasoningLevelChange({ + ...input, + models: modelWith([selectDescriptor(id, options, "high")]), + }), + ).toBeNull(); + }); + + it("does nothing when the model is unavailable", () => { + expect(getReasoningLevelChange({ ...input, models: [] })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..8db739869839 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -215,6 +215,68 @@ function getSelectedTraits( }; } +/** Resolve a picker or shortcut selection; prompt-only changes omit modelOptions to preserve preferences. */ +function getTraitSelectChange( + selected: ReturnType, + descriptor: Extract, + prompt: string, + value: string, +) { + if (!value) return null; + if (descriptor.promptInjectedValues?.includes(value)) { + return { + prompt: + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"), + }; + } + const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id; + if (isPrimary && selected.ultrathinkInBodyText) return null; + return { + prompt: + isPrimary && selected.ultrathinkPromptControlled + ? prompt.replace(/^Ultrathink:\s*/i, "") + : prompt, + modelOptions: buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(selected.descriptors, descriptor.id, value), + ), + }; +} + +/** Step through the model's reasoning choices, stopping at either end. */ +export function getReasoningLevelChange(input: { + provider: ProviderDriverKind; + models: ReadonlyArray; + model: string; + prompt: string; + modelOptions: ProviderOptions | null | undefined; + planModeEnabled: boolean; + direction: 1 | -1; +}) { + const selected = getSelectedTraits( + input.provider, + input.models, + input.model, + input.prompt, + input.modelOptions, + true, + input.planModeEnabled, + ); + const descriptor = selected.selectDescriptors.find((entry) => + ["reasoningEffort", "effort", "variant"].includes(entry.id), + ); + if (!descriptor || selected.modelIsUnavailable || selected.ultrathinkInBodyText) return null; + const current = selected.ultrathinkPromptControlled + ? "ultrathink" + : getProviderOptionCurrentValue(descriptor); + const index = descriptor.options.findIndex((option) => option.id === current); + if (index < 0) return null; + const next = descriptor.options[index + input.direction]; + if (!next) return null; + return getTraitSelectChange(selected, descriptor, input.prompt, next.id); +} + function getTraitsSectionVisibility(input: { provider: ProviderDriverKind; models: ReadonlyArray; @@ -315,6 +377,15 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ }, [instanceId, model, persistence, provider, setProviderModelOptions], ); + const selected = getTraitsSectionVisibility({ + provider, + models, + model, + prompt, + modelOptions, + allowPromptInjectedEffort, + planModeEnabled, + }); const { descriptors, selectDescriptors, @@ -324,38 +395,16 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ultrathinkInBodyText, hasAnyControls, modelIsUnavailable, - } = getTraitsSectionVisibility({ - provider, - models, - model, - prompt, - modelOptions, - allowPromptInjectedEffort, - planModeEnabled, - }); - const updateDescriptors = (nextDescriptors: ReadonlyArray) => { - updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); - }; + } = selected; const handleSelectChange = ( descriptor: Extract, value: string, ) => { - if (!value) return; - if (descriptor.promptInjectedValues?.includes(value)) { - const nextPrompt = - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"); - onPromptChange(nextPrompt); - return; - } - if (ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id) return; - if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { - const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); - onPromptChange(stripped); - } - updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); + const change = getTraitSelectChange(selected, descriptor, prompt, value); + if (!change) return; + if (change.prompt !== prompt) onPromptChange(change.prompt); + if ("modelOptions" in change) updateModelOptions(change.modelOptions); }; if (!hasAnyControls) { @@ -457,8 +506,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ { - updateDescriptors( - replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), + updateModelOptions( + buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), + ), ); }} > diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 2be8aa91f7aa..e96d5e2230a6 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -73,6 +73,8 @@ export const STATIC_KEYBINDING_COMMANDS = [ "projectSearch.toggle", "themeEditor.toggle", "composer.stash", + "reasoning.increase", + "reasoning.decrease", "chat.new", "chat.newLocal", "editor.openFavorite",