diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 9caddbfae1..bb208b7094 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -2268,7 +2268,7 @@ const ChatInputInner: React.FC = (props) => { className="flex items-center [&_.thinking-slider]:[@container(max-width:550px)]:hidden" data-component="ThinkingSliderGroup" > - +
diff --git a/src/browser/components/ThinkingSlider.tsx b/src/browser/components/ThinkingSlider.tsx index 792c28538c..05c96e5d14 100644 --- a/src/browser/components/ThinkingSlider.tsx +++ b/src/browser/components/ThinkingSlider.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useId } from "react"; import type { ThinkingLevel } from "@/common/types/thinking"; +import { useThinkingModel } from "@/browser/contexts/ThinkingContext"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; @@ -59,12 +60,9 @@ const getSliderStyles = (value: number, isHover = false) => { }; }; -interface ThinkingControlProps { - modelString: string; -} - -export const ThinkingSliderComponent: React.FC = ({ modelString }) => { +export const ThinkingSliderComponent: React.FC = () => { const [thinkingLevel, setThinkingLevel] = useThinkingLevel(); + const modelString = useThinkingModel(); const [isHovering, setIsHovering] = React.useState(false); const sliderId = useId(); const allowed = getThinkingPolicyForModel(modelString); diff --git a/src/browser/components/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync.tsx index 41de181521..bb06ea4ed5 100644 --- a/src/browser/components/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync.tsx @@ -14,11 +14,23 @@ import { MODE_AI_DEFAULTS_KEY, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; +import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; +import { resolveModelAlias } from "@/common/utils/ai/models"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; +const normalizeModelString = (model: string, fallback: string): string => { + const trimmed = model.trim(); + if (!trimmed) { + return fallback; + } + + const canonical = migrateGatewayModel(resolveModelAlias(trimmed)).trim(); + return canonical.length > 0 ? canonical : fallback; +}; + type WorkspaceAISettingsCache = Partial< Record >; @@ -75,6 +87,7 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { typeof candidateModel === "string" && candidateModel.trim().length > 0 ? candidateModel : fallbackModel; + const effectiveModel = normalizeModelString(resolvedModel, fallbackModel); const existingThinking = readPersistedState(thinkingKey, "off"); const candidateThinking = @@ -86,10 +99,10 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { "off"; const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off"; - const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking); + const effectiveThinking = enforceThinkingPolicy(effectiveModel, resolvedThinking); - if (existingModel !== resolvedModel) { - updatePersistedState(modelKey, resolvedModel); + if (existingModel !== effectiveModel) { + updatePersistedState(modelKey, effectiveModel); } if (existingThinking !== effectiveThinking) { diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 2a55d452ac..2d3f6671ad 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -22,6 +22,7 @@ import { useAPI } from "@/browser/contexts/API"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; interface ThinkingContextType { + model: string; thinkingLevel: ThinkingLevel; setThinkingLevel: (level: ThinkingLevel) => void; } @@ -38,17 +39,20 @@ function getScopeId(workspaceId: string | undefined, projectPath: string | undef return workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); } -function getCanonicalModelForScope(scopeId: string, fallbackModel: string): string { - const rawModel = readPersistedState(getModelKey(scopeId), fallbackModel); - return migrateGatewayModel(rawModel || fallbackModel); -} - export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); const defaultModel = getDefaultModel(); const scopeId = getScopeId(props.workspaceId, props.projectPath); const thinkingKey = getThinkingLevelKey(scopeId); + const [rawModel] = usePersistedState(getModelKey(scopeId), defaultModel, { + listener: true, + }); + const canonicalModel = useMemo( + () => migrateGatewayModel(rawModel || defaultModel), + [rawModel, defaultModel] + ); + // Workspace-scoped thinking. (No longer per-model.) const [thinkingLevel, setThinkingLevelInternal] = usePersistedState( thinkingKey, @@ -63,21 +67,19 @@ export const ThinkingProvider: React.FC = (props) => { return; } - const model = getCanonicalModelForScope(scopeId, defaultModel); - const legacyKey = getThinkingLevelByModelKey(model); + const legacyKey = getThinkingLevelByModelKey(canonicalModel); const legacy = readPersistedState(legacyKey, undefined); if (legacy === undefined) { return; } - const effective = enforceThinkingPolicy(model, legacy); + const effective = enforceThinkingPolicy(canonicalModel, legacy); updatePersistedState(thinkingKey, effective); - }, [defaultModel, scopeId, thinkingKey]); + }, [canonicalModel, thinkingKey]); const setThinkingLevel = useCallback( (level: ThinkingLevel) => { - const model = getCanonicalModelForScope(scopeId, defaultModel); - const effective = enforceThinkingPolicy(model, level); + const effective = enforceThinkingPolicy(canonicalModel, level); setThinkingLevelInternal(effective); @@ -99,7 +101,7 @@ export const ThinkingProvider: React.FC = (props) => { prev && typeof prev === "object" ? prev : {}; return { ...record, - [agentId]: { model, thinkingLevel: effective }, + [agentId]: { model: canonicalModel, thinkingLevel: effective }, }; }, {} @@ -115,13 +117,13 @@ export const ThinkingProvider: React.FC = (props) => { .updateModeAISettings({ workspaceId: props.workspaceId, mode: agentId, - aiSettings: { model, thinkingLevel: effective }, + aiSettings: { model: canonicalModel, thinkingLevel: effective }, }) .catch(() => { // Best-effort only. If offline or backend is old, the next sendMessage will persist. }); }, - [api, defaultModel, props.workspaceId, scopeId, setThinkingLevelInternal] + [api, canonicalModel, props.workspaceId, scopeId, setThinkingLevelInternal] ); // Global keybind: cycle thinking level (Ctrl/Cmd+Shift+T). @@ -133,10 +135,13 @@ export const ThinkingProvider: React.FC = (props) => { return; } + if (e.repeat) { + return; + } + e.preventDefault(); - const model = getCanonicalModelForScope(scopeId, defaultModel); - const allowed = getThinkingPolicyForModel(model); + const allowed = getThinkingPolicyForModel(canonicalModel); if (allowed.length <= 1) { return; } @@ -148,17 +153,20 @@ export const ThinkingProvider: React.FC = (props) => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [defaultModel, scopeId, thinkingLevel, setThinkingLevel]); + }, [canonicalModel, thinkingLevel, setThinkingLevel]); // Memoize context value to prevent unnecessary re-renders of consumers. const contextValue = useMemo( - () => ({ thinkingLevel, setThinkingLevel }), - [thinkingLevel, setThinkingLevel] + () => ({ model: canonicalModel, thinkingLevel, setThinkingLevel }), + [canonicalModel, thinkingLevel, setThinkingLevel] ); return {props.children}; }; +export const useThinkingModel = () => { + return useThinking().model; +}; export const useThinking = () => { const context = useContext(ThinkingContext); if (!context) { diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index af8d430dcd..44b5f8a70c 100644 --- a/src/common/utils/thinking/policy.test.ts +++ b/src/common/utils/thinking/policy.test.ts @@ -64,6 +64,15 @@ describe("getThinkingPolicyForModel", () => { ]); }); + test("returns 5 levels including xhigh for gpt-5.2-codex preview suffix", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.2-codex-2025-12-11-preview")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + ]); + }); test("returns 5 levels including xhigh for gpt-5.2-codex", () => { expect(getThinkingPolicyForModel("openai:gpt-5.2-codex")).toEqual([ "off", @@ -113,6 +122,24 @@ describe("getThinkingPolicyForModel", () => { "xhigh", ]); }); + + test("returns default levels for gpt-5.2-pro-mini", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.2-pro-mini")).toEqual([ + "off", + "low", + "medium", + "high", + ]); + }); + + test("returns default levels for gpt-5.2-codex-mini", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.2-codex-mini")).toEqual([ + "off", + "low", + "medium", + "high", + ]); + }); test("returns medium/high/xhigh for gpt-5.2-pro with version suffix", () => { expect(getThinkingPolicyForModel("openai:gpt-5.2-pro-2025-12-11")).toEqual([ "medium", diff --git a/src/common/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts index ac76a20840..bb467495e2 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -53,17 +53,20 @@ export function getThinkingPolicyForModel(modelString: string): ThinkingPolicy { } // GPT-5.2-Codex supports 5 reasoning levels including xhigh (Extra High) - if (/^gpt-5\.2-codex(?!-[a-z])/.test(withoutProviderNamespace)) { + // Allow version suffixes like -2025-12-11-preview, but exclude mini variants. + if (/^gpt-5\.2-codex(?!-mini\b)/.test(withoutProviderNamespace)) { return ["off", "low", "medium", "high", "xhigh"]; } // gpt-5.2-pro supports medium, high, xhigh reasoning levels - if (/^gpt-5\.2-pro(?!-[a-z])/.test(withoutProviderNamespace)) { + // Allow version suffixes like -2025-12-11-preview, but exclude mini variants. + if (/^gpt-5\.2-pro(?!-mini\b)/.test(withoutProviderNamespace)) { return ["medium", "high", "xhigh"]; } // gpt-5.2 supports 5 reasoning levels including xhigh (Extra High) - if (/^gpt-5\.2(?!-[a-z])/.test(withoutProviderNamespace)) { + // Allow version suffixes like -2025-12-11-preview, but exclude any mini variants. + if (/^gpt-5\.2(?!.*-mini\b)/.test(withoutProviderNamespace)) { return ["off", "low", "medium", "high", "xhigh"]; } diff --git a/tests/ui/thinkingKeybind.integration.test.ts b/tests/ui/thinkingKeybind.integration.test.ts new file mode 100644 index 0000000000..f3e0d09654 --- /dev/null +++ b/tests/ui/thinkingKeybind.integration.test.ts @@ -0,0 +1,133 @@ +import { act, waitFor } from "@testing-library/react"; + +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { getModelKey, getThinkingLevelKey, MODE_AI_DEFAULTS_KEY } from "@/common/constants/storage"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; + +const getActiveThinkingSlider = (container: HTMLElement): HTMLInputElement => { + const sliders = Array.from( + container.querySelectorAll('input[aria-label="Thinking level"]') + ) as HTMLInputElement[]; + + if (sliders.length === 0) { + throw new Error("Thinking slider not found"); + } + + return sliders[sliders.length - 1]; +}; + +import { createAppHarness } from "./harness"; + +describe("Thinking keybind (UI)", () => { + test("normalizes codex alias in mode defaults", async () => { + const app = await createAppHarness({ branchPrefix: "thinking-keybind-defaults" }); + + try { + act(() => { + updatePersistedState(MODE_AI_DEFAULTS_KEY, { + exec: { modelString: "codex", thinkingLevel: "high" }, + }); + }); + + await waitFor(() => { + const model = readPersistedState(getModelKey(app.workspaceId), ""); + expect(model).toBe("openai:gpt-5.2-codex"); + }); + + await waitFor(() => { + const slider = getActiveThinkingSlider(app.view.container); + if (slider.getAttribute("aria-valuetext") !== "high") { + throw new Error("Thinking level has not updated to high"); + } + expect(slider.getAttribute("aria-valuemax")).toBe("4"); + }); + + act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "T", + ctrlKey: true, + shiftKey: true, + }) + ); + }); + + await waitFor(() => { + const slider = getActiveThinkingSlider(app.view.container); + if (slider.getAttribute("aria-valuetext") !== "xhigh") { + throw new Error("Thinking level did not advance to xhigh"); + } + }); + } finally { + act(() => { + updatePersistedState(MODE_AI_DEFAULTS_KEY, {}); + }); + await app.dispose(); + } + }); + + test("cycles to xhigh for gpt-5.2-codex (selected via /model)", async () => { + const app = await createAppHarness({ branchPrefix: "thinking-keybind" }); + + try { + await app.chat.send("/model codex"); + + await waitFor(() => { + const model = readPersistedState(getModelKey(app.workspaceId), ""); + expect(model).toBe("openai:gpt-5.2-codex"); + }); + + // Start from a deterministic thinking level so we can assert the next step. + act(() => { + updatePersistedState(getThinkingLevelKey(app.workspaceId), "high"); + }); + + await waitFor(() => { + const slider = getActiveThinkingSlider(app.view.container); + if (slider.getAttribute("aria-valuetext") !== "high") { + throw new Error("Thinking level has not updated to high"); + } + + // 5 allowed levels means max index 4. + expect(slider.getAttribute("aria-valuemax")).toBe("4"); + }); + + act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "T", + ctrlKey: true, + shiftKey: true, + }) + ); + }); + + await waitFor(() => { + const slider = getActiveThinkingSlider(app.view.container); + if (slider.getAttribute("aria-valuetext") !== "xhigh") { + throw new Error("Thinking level did not advance to xhigh"); + } + }); + + act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "T", + ctrlKey: true, + shiftKey: true, + repeat: true, + }) + ); + }); + + await waitFor(() => { + const slider = getActiveThinkingSlider(app.view.container); + if (slider.getAttribute("aria-valuetext") !== "xhigh") { + throw new Error("Thinking level should ignore repeated keydown"); + } + }); + } finally { + await app.dispose(); + } + }); +});