diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index fb256f25500a..eb718cbd95d4 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,6 +188,35 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("ships configurable thread navigation defaults", () => + Effect.sync(() => { + const defaultsByCommand = new Map( + Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), + ); + + assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); + assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); + assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); + assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); + assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); + assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); + assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); + assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); + assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); + assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); + assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("reasoning.decrease"), "mod+alt+,"); + assert.equal(defaultsByCommand.get("reasoning.increase"), "mod+alt+."); + }), + ); + it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..eaa3344dc2e1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -49,8 +49,12 @@ import { wouldTextPasteExceedLimit, } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { + createModelSelection, + normalizeModelSlug, + resolveReasoningTransition, +} from "@t3tools/shared/model"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; -import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, @@ -76,6 +80,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, + mapComposerCursorAcrossLeadingPromptChange, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; @@ -178,7 +183,11 @@ import { import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; +import { + reasoningCycleDirectionFromCommand, + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, @@ -943,6 +952,7 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; +import { getProviderModelCapabilities } from "../../providerModels"; import { hasProviderSetup } from "./ProviderStatusBanner"; import { applyProviderInstanceSettings, @@ -1745,6 +1755,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }) : null); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); @@ -3054,10 +3065,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Sync refs back to parent // ------------------------------------------------------------------ + const restoreDraftAfterPendingRef = useRef(false); useEffect(() => { + // While a pending question is visible, promptRef is temporarily the + // custom-answer buffer. Restore the draft as soon as that editor state + // ends, even when the draft prompt itself did not change. + if (activePendingProgress !== null) { + restoreDraftAfterPendingRef.current = true; + return; + } promptRef.current = prompt; + if (restoreDraftAfterPendingRef.current) { + // The cursor and trigger still describe the custom-answer buffer, or a + // draft that changed while hidden behind an approval, so rebuild both + // from the restored draft instead of clamping them. + restoreDraftAfterPendingRef.current = false; + setComposerCursor(collapseExpandedComposerCursor(prompt, prompt.length)); + setComposerTrigger(detectComposerTrigger(prompt, prompt.length)); + return; + } setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + }, [activePendingProgress, prompt, promptRef]); useEffect(() => { if (composerSubmissionError === null) return; @@ -5207,6 +5235,87 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false); }, [prompt]); + const applyReasoningCycle = useCallback( + (direction: "increase" | "decrease") => { + const editorShowsDraftPrompt = !isComposerApprovalState && activePendingProgress === null; + const currentPrompt = editorShowsDraftPrompt + ? promptRef.current + : (getComposerDraft(composerDraftTarget)?.prompt ?? ""); + const currentModelOptions = composerModelOptions?.[selectedInstanceId]; + const transition = resolveReasoningTransition({ + capabilities: getProviderModelCapabilities( + selectedProviderModels, + selectedModel, + selectedProvider, + settings.planModeEnabled, + ), + modelOptions: currentModelOptions, + prompt: currentPrompt, + action: { type: "cycle", direction }, + }); + if (transition.status === "blocked") { + toastManager.add({ + type: "info", + title: "Remove “ultrathink” from the prompt text to change reasoning.", + }); + return; + } + if (transition.status === "unsupported") { + toastManager.add({ + type: "info", + title: "This model has no reasoning levels to switch between.", + }); + return; + } + if (transition.status !== "changed") return; + if (transition.prompt !== currentPrompt) { + setPrompt(transition.prompt); + // When a pending question or approval hides the editor, the draft + // is updated in the store only. The cursor and trigger belong to + // the visible editor state and are rebuilt from the draft by the + // sync effect once it is shown again. + if (!editorShowsDraftPrompt) { + restoreDraftAfterPendingRef.current = true; + } else { + // The editor is mounted whenever it shows the draft; the fallback + // only guards the ref during teardown. + const currentExpandedCursor = + composerEditorRef.current?.readSnapshot().expandedCursor ?? currentPrompt.length; + const nextExpandedCursor = mapComposerCursorAcrossLeadingPromptChange( + currentPrompt, + transition.prompt, + currentExpandedCursor, + ); + promptRef.current = transition.prompt; + setComposerCursor(collapseExpandedComposerCursor(transition.prompt, nextExpandedCursor)); + setComposerTrigger(detectComposerTrigger(transition.prompt, nextExpandedCursor)); + } + } + if (transition.modelOptions !== currentModelOptions) { + setProviderModelOptions(composerDraftTarget, selectedProvider, transition.modelOptions, { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }); + } + }, + [ + activePendingProgress, + composerDraftTarget, + composerModelOptions, + getComposerDraft, + isComposerApprovalState, + promptRef, + selectedInstanceId, + selectedModel, + selectedProvider, + selectedProviderModels, + setPrompt, + setProviderModelOptions, + settings.planModeEnabled, + ], + ); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { const command = resolveShortcutCommand(event, keybindings, { @@ -5216,14 +5325,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelPickerOpen: isComposerModelPickerOpen, }, }); - if (command !== "composer.stash") return; - // Always claim the shortcut so the browser save dialog never opens, - // even when the composer is in a state that can't stash. + const reasoningDirection = reasoningCycleDirectionFromCommand(command); + if (command !== "composer.stash" && reasoningDirection === null) return; + // Always claim recognized composer shortcuts, including in states where + // the requested mutation is unavailable. event.preventDefault(); event.stopPropagation(); + if (reasoningDirection !== null && event.repeat) return; if (isCommandPaletteOpen() || isRevertingCheckpoint) { return; } + if (reasoningDirection !== null) { + applyReasoningCycle(reasoningDirection); + return; + } if (pendingUserInputs.length > 0 && !isComposerApprovalState) { setIsStashMenuOpen((open) => !open); return; @@ -5237,6 +5352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return () => window.removeEventListener("keydown", handler, true); }, [ activePendingProgress, + applyReasoningCycle, isComposerApprovalState, isComposerModelPickerOpen, keybindings, diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index dbe97cdd54e0..ca4d7e73717e 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; -import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; +import { + buildTraitsTriggerDisplay, + buildUnavailableModelOptionDescriptors, + resolveTraitsSelectChange, +} from "./TraitsPicker"; function selectDescriptor( id: string, @@ -156,6 +160,57 @@ describe("buildTraitsTriggerDisplay", () => { }); }); +describe("resolveTraitsSelectChange", () => { + it("routes reasoning selections through the shared preserving transition", () => { + const modelOptions = [ + { id: "future", value: "keep" }, + { id: "reasoningEffort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ] as const; + + expect( + resolveTraitsSelectChange({ + caps: { optionDescriptors: [EFFORT, CONTEXT_WINDOW] }, + descriptors: [EFFORT, CONTEXT_WINDOW], + descriptor: EFFORT, + value: "max", + prompt: "unchanged prompt", + modelOptions, + }), + ).toEqual({ + prompt: "unchanged prompt", + modelOptions: [ + { id: "future", value: "keep" }, + { id: "reasoningEffort", value: "max" }, + { id: "contextWindow", value: "1m" }, + ], + }); + expect(modelOptions[1]?.value).toBe("high"); + }); + + it("retains the generic descriptor path for non-reasoning traits", () => { + expect( + resolveTraitsSelectChange({ + caps: { optionDescriptors: [EFFORT, CONTEXT_WINDOW] }, + descriptors: [EFFORT, CONTEXT_WINDOW], + descriptor: CONTEXT_WINDOW, + value: "200k", + prompt: "unchanged prompt", + modelOptions: [ + { id: "reasoningEffort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + }), + ).toEqual({ + prompt: "unchanged prompt", + modelOptions: [ + { id: "reasoningEffort", value: "high" }, + { id: "contextWindow", value: "200k" }, + ], + }); + }); +}); + describe("buildUnavailableModelOptionDescriptors", () => { it("shows only saved values without inventing alternatives", () => { expect( diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 78c70152710d..1e73c5297ec8 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -7,13 +7,15 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { - applyClaudePromptEffortPrefix, buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, + isReasoningDescriptorId, normalizeModelSlug, + resolveReasoningTransition, + stripClaudeUltrathinkPrefix, } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; import { BrainIcon, ZapIcon } from "lucide-react"; @@ -89,8 +91,6 @@ type TraitsPersistence = onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; -const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; - function DefaultBadge() { return ( @@ -129,6 +129,33 @@ function getDescriptorStringValue( return typeof value === "string" ? value : null; } +export function resolveTraitsSelectChange(input: { + caps: ReturnType; + descriptors: ReadonlyArray; + descriptor: Extract; + value: string; + prompt: string; + modelOptions: ProviderOptions | null | undefined; +}): { prompt: string; modelOptions: ProviderOptions | undefined } | null { + if (!isReasoningDescriptorId(input.descriptor.id)) { + return { + prompt: input.prompt, + modelOptions: buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(input.descriptors, input.descriptor.id, input.value), + ), + }; + } + const transition = resolveReasoningTransition({ + capabilities: input.caps, + modelOptions: input.modelOptions, + prompt: input.prompt, + action: { type: "select", descriptorId: input.descriptor.id, value: input.value }, + }); + return transition.status === "changed" + ? { prompt: transition.prompt, modelOptions: transition.modelOptions } + : null; +} + function getSelectedTraits( provider: ProviderDriverKind, models: ReadonlyArray, @@ -177,7 +204,7 @@ function getSelectedTraits( // Check if "ultrathink" appears in the body text (not just our prefix) const ultrathinkInBodyText = - ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); + ultrathinkPromptControlled && isClaudeUltrathinkPrompt(stripClaudeUltrathinkPrefix(prompt)); const effort = (ultrathinkPromptControlled ? "ultrathink" @@ -311,6 +338,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ [instanceId, model, persistence, provider, setProviderModelOptions], ); const { + caps, descriptors, selectDescriptors, booleanDescriptors, @@ -337,20 +365,21 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ 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; + const change = resolveTraitsSelectChange({ + caps, + descriptors, + descriptor, + value, + prompt, + modelOptions, + }); + if (!change) return; + if (change.prompt !== prompt) { + onPromptChange(change.prompt); } - if (ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id) return; - if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { - const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); - onPromptChange(stripped); + if (change.modelOptions !== (modelOptions ?? undefined)) { + updateModelOptions(change.modelOptions); } - updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); }; if (!hasAnyControls) { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7729ce183754..db3d3e85b7d0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -149,6 +149,35 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+m"); }); + it("records macOS Option punctuation using portable key names", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "≤", + code: "Comma", + metaKey: false, + ctrlKey: false, + altKey: true, + shiftKey: false, + }, + "MacIntel", + ), + ).toBe("alt+,"); + expect( + keybindingFromKeyboardEvent( + { + key: "≥", + code: "Period", + metaKey: false, + ctrlKey: false, + altKey: true, + shiftKey: false, + }, + "MacIntel", + ), + ).toBe("alt+."); + }); + it("serializes shortcuts and when expressions for upserts", () => { expect( shortcutToKeybindingInput({ diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 83f143a546fd..9e520855ef18 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -16,6 +16,7 @@ import { expandCollapsedComposerCursor, formatAssistantCitationForComposer, isCollapsedCursorAdjacentToInlineToken, + mapComposerCursorAcrossLeadingPromptChange, parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -62,6 +63,30 @@ describe("formatAssistantCitationForComposer", () => { }); }); +describe("mapComposerCursorAcrossLeadingPromptChange", () => { + it("keeps the caret beside the same draft text when a prefix is added or removed", () => { + const prefix = "Ultrathink:\n"; + const prompt = "Investigate this failure"; + + expect(mapComposerCursorAcrossLeadingPromptChange(prompt, `${prefix}${prompt}`, 12)).toBe( + prefix.length + 12, + ); + expect( + mapComposerCursorAcrossLeadingPromptChange(`${prefix}${prompt}`, prompt, prefix.length + 12), + ).toBe(12); + }); + + it("moves a caret inside a removed prefix to the start of the remaining prompt", () => { + expect( + mapComposerCursorAcrossLeadingPromptChange( + "Ultrathink:\nInvestigate this failure", + "Investigate this failure", + 4, + ), + ).toBe(0); + }); +}); + describe("composerSubmissionIntentForEnter", () => { it.each([ ["enter", "one line", false, "foreground"], diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index ff638a6d2e46..6668983e427c 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -51,6 +51,22 @@ function clampCursor(text: string, cursor: number): number { return Math.max(0, Math.min(text.length, Math.floor(cursor))); } +export function mapComposerCursorAcrossLeadingPromptChange( + currentPrompt: string, + nextPrompt: string, + cursorInput: number, +): number { + const currentCursor = clampCursor(currentPrompt, cursorInput); + const lengthDelta = nextPrompt.length - currentPrompt.length; + if (lengthDelta > 0 && nextPrompt.slice(lengthDelta) === currentPrompt) { + return currentCursor + lengthDelta; + } + if (lengthDelta < 0 && currentPrompt.slice(-lengthDelta) === nextPrompt) { + return Math.max(0, currentCursor + lengthDelta); + } + return clampCursor(nextPrompt, currentCursor); +} + function isWhitespace(char: string): boolean { return char === " " || char === "\n" || char === "\t" || char === "\r"; } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a5fc2374be56..158f9dbdfa61 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -24,6 +24,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + reasoningCycleDirectionFromCommand, resolveShortcutCommand, shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, @@ -61,6 +62,17 @@ function modShortcut( }; } +function altShortcut(key: string): KeybindingShortcut { + return { + key, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + modKey: false, + }; +} + function whenIdentifier(name: string): KeybindingWhenNode { return { type: "identifier", name }; } @@ -148,6 +160,16 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, + { + shortcut: modShortcut(",", { altKey: true }), + command: "reasoning.decrease", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut(".", { altKey: true }), + command: "reasoning.increase", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, { @@ -601,6 +623,141 @@ describe("thread navigation helpers", () => { }); }); +describe("reasoning cycle shortcuts", () => { + it("maps commands to cycle directions", () => { + assert.strictEqual(reasoningCycleDirectionFromCommand("reasoning.decrease"), "decrease"); + assert.strictEqual(reasoningCycleDirectionFromCommand("reasoning.increase"), "increase"); + assert.isNull(reasoningCycleDirectionFromCommand("thread.next")); + assert.isNull(reasoningCycleDirectionFromCommand(null)); + }); + + it("matches macOS Mod+Option punctuation by physical key code", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ + key: "≤", + code: "Comma", + metaKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), + "reasoning.decrease", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ + key: "≥", + code: "Period", + metaKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), + "reasoning.increase", + ); + }); + + it("matches only the reported layout key during AltGraph input", () => { + const getAltGraphModifierState = (modifier: string) => modifier === "AltGraph"; + assert.isNull( + resolveShortcutCommand( + event({ + key: "<", + code: "Comma", + ctrlKey: true, + altKey: true, + getModifierState: getAltGraphModifierState, + }), + DEFAULT_BINDINGS, + { platform: "Win32", context: { terminalFocus: false } }, + ), + ); + assert.isNull( + resolveShortcutCommand( + event({ + key: ">", + code: "Period", + ctrlKey: true, + altKey: true, + getModifierState: getAltGraphModifierState, + }), + DEFAULT_BINDINGS, + { platform: "Linux", context: { terminalFocus: false } }, + ), + ); + assert.strictEqual( + resolveShortcutCommand( + event({ + key: ",", + code: "Comma", + ctrlKey: true, + altKey: true, + getModifierState: getAltGraphModifierState, + }), + DEFAULT_BINDINGS, + { platform: "Win32", context: { terminalFocus: false } }, + ), + "reasoning.decrease", + ); + }); + + it("leaves terminal focus untouched and preserves later-binding precedence", () => { + assert.isNull( + resolveShortcutCommand( + event({ key: ",", code: "Comma", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: true }, + }, + ), + ); + + const overridden = compile([ + { shortcut: altShortcut(","), command: "reasoning.decrease" }, + { shortcut: altShortcut(","), command: "diff.toggle" }, + ]); + assert.strictEqual( + resolveShortcutCommand(event({ key: ",", code: "Comma", altKey: true }), overridden, { + platform: "MacIntel", + }), + "diff.toggle", + ); + }); + + it("uses later-binding precedence when layout and physical aliases both match", () => { + const layoutFirst = compile([ + { shortcut: altShortcut("≤"), command: "diff.toggle" }, + { shortcut: altShortcut(","), command: "reasoning.decrease" }, + ]); + const physicalFirst = compile([ + { shortcut: altShortcut(","), command: "reasoning.decrease" }, + { shortcut: altShortcut("≤"), command: "diff.toggle" }, + ]); + const optionComma = event({ key: "≤", code: "Comma", altKey: true }); + + assert.strictEqual( + resolveShortcutCommand(optionComma, layoutFirst, { platform: "MacIntel" }), + "reasoning.decrease", + ); + assert.strictEqual( + resolveShortcutCommand(optionComma, physicalFirst, { platform: "MacIntel" }), + "diff.toggle", + ); + }); +}); + describe("model picker navigation helpers", () => { it("maps jump commands to visible model indices", () => { assert.strictEqual(modelPickerJumpCommandForIndex(0), "modelPicker.jump.1"); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 73b59bc20b29..227f1781ea9b 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -86,6 +86,14 @@ function normalizeEventKey(key: string): string { return normalized; } +function isAltGraphShortcutEvent( + event: Pick, + platform: string, +): boolean { + // Firefox reports ordinary Option presses as AltGraph on macOS. + return !isMacPlatform(platform) && event.getModifierState?.("AltGraph") === true; +} + export function shortcutKeyFromEvent(event: Pick): string { const layoutKey = normalizeEventKey(event.key); if (/^[a-z]$/.test(layoutKey)) return layoutKey; @@ -93,9 +101,12 @@ export function shortcutKeyFromEvent(event: Pick { +function resolveEventKeys(event: ShortcutEventLike, platform: string): Set { const layoutKey = normalizeEventKey(event.key); const keys = new Set([layoutKey]); + // AltGraph can surface as Ctrl+Alt, so retain its layout key while avoiding + // physical aliases that would turn typed punctuation into a shortcut. + if (isAltGraphShortcutEvent(event, platform)) return keys; // The physical-position fallback exists for layouts that type non-Latin // letters (Cyrillic, Greek) and for Option-modified symbols on macOS. // When the layout already produces a Latin letter, match on it alone; @@ -130,14 +141,8 @@ function matchesShortcut( shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { - if ( - !isMacPlatform(platform) && - event.getModifierState?.("AltGraph") && - !/^[a-z0-9]$/i.test(event.key) - ) - return false; if (!matchesShortcutModifiers(event, shortcut, platform)) return false; - return resolveEventKeys(event).has(shortcut.key); + return resolveEventKeys(event, platform).has(shortcut.key); } function resolvePlatform(options: ShortcutMatchOptions | undefined): string { @@ -318,6 +323,14 @@ export function threadTraversalDirectionFromCommand( return null; } +export function reasoningCycleDirectionFromCommand( + command: string | null, +): "decrease" | "increase" | null { + if (command === "reasoning.decrease") return "decrease"; + if (command === "reasoning.increase") return "increase"; + return null; +} + export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, @@ -420,12 +433,15 @@ export function isOpenFavoriteEditorShortcut( * modifiers). Tiptap binds the same chord, so app shortcuts captured ahead * of the editor must yield when the rich-text composer is focused. */ -export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean { +export function isRichTextBoldShortcut( + event: ShortcutEventLike, + platform = navigator.platform, +): boolean { if (event.type !== undefined && event.type !== "keydown") { return false; } return ( - resolveEventKeys(event).has("b") && + resolveEventKeys(event, platform).has("b") && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey diff --git a/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb121..a6254c3e0eb6 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -70,6 +70,10 @@ returns to the remembered selection. Leaving reasoning level or service tier unset uses the provider's own configuration. +Step the reasoning level up or down with `Cmd+Option+.` and `Cmd+Option+,` (`Ctrl+Alt` on +Windows and Linux). Remap them in Settings → Keybindings as `reasoning.increase` and +`reasoning.decrease`. + ## Quote an assistant response On web and desktop, select text within one assistant response and choose diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index dee46551c224..a58a0485a1ef 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -114,6 +114,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); + const parsedReasoningDecrease = yield* decode(KeybindingRule, { + key: "alt+,", + command: "reasoning.decrease", + }); + assert.strictEqual(parsedReasoningDecrease.command, "reasoning.decrease"); + + const parsedReasoningIncrease = yield* decode(KeybindingRule, { + key: "alt+.", + command: "reasoning.increase", + }); + assert.strictEqual(parsedReasoningIncrease.command, "reasoning.increase"); + const parsedThreadSettle = yield* decode(KeybindingRule, { key: "mod+shift+s", command: "thread.settle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 239f9905c534..e511cbf54ce3 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -92,6 +92,8 @@ export const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + "reasoning.decrease", + "reasoning.increase", "usage.cost", "usage.tokens", "usage.limits", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939..c1fca4b4b701 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -45,6 +45,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, + { key: "mod+alt+,", command: "reasoning.decrease", when: "!terminalFocus" }, + { key: "mod+alt+.", command: "reasoning.increase", when: "!terminalFocus" }, { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index d2c64e3d9458..96a0e85f7fd5 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { + ProviderInstanceId, + type ModelCapabilities, + type ProviderOptionSelection, +} from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -14,6 +18,8 @@ import { toCustomModelSetting, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, + resolveReasoningTransition, + stripClaudeUltrathinkPrefix, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -165,6 +171,439 @@ describe("descriptor helpers", () => { }); }); +function transition(input: { + capabilities?: ModelCapabilities; + modelOptions?: ReadonlyArray; + prompt?: string; + action?: + | { type: "cycle"; direction: "increase" | "decrease" } + | { type: "select"; descriptorId: string; value: string }; +}) { + return resolveReasoningTransition({ + capabilities: input.capabilities ?? codexCaps, + modelOptions: input.modelOptions, + prompt: input.prompt ?? "", + action: input.action ?? { type: "cycle", direction: "increase" }, + }); +} + +describe("resolveReasoningTransition", () => { + it("cycles Codex in advertised order in both directions with wraparound", () => { + expect(transition({ modelOptions: [{ id: "reasoningEffort", value: "xhigh" }] })).toEqual({ + status: "changed", + prompt: "", + modelOptions: [{ id: "reasoningEffort", value: "high" }], + value: "high", + label: "High", + }); + expect( + transition({ + modelOptions: [{ id: "reasoningEffort", value: "high" }], + action: { type: "cycle", direction: "decrease" }, + }), + ).toEqual({ + status: "changed", + prompt: "", + modelOptions: [{ id: "reasoningEffort", value: "xhigh" }], + value: "xhigh", + label: "Extra High", + }); + }); + + it("never strips an Ultrathink prefix from providers that do not own it", () => { + expect( + transition({ + modelOptions: [{ id: "reasoningEffort", value: "high" }], + prompt: "Ultrathink:\nthis is user text", + }), + ).toEqual( + expect.objectContaining({ + prompt: "Ultrathink:\nthis is user text", + value: "xhigh", + }), + ); + }); + + it("supports Cursor's normalized reasoning descriptor", () => { + const cursorCaps = createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoning", + label: "Reasoning", + type: "select", + options: [ + { id: "off", label: "Off" }, + { id: "on", label: "On" }, + ], + currentValue: "off", + }, + ], + }); + + expect(transition({ capabilities: cursorCaps })).toEqual( + expect.objectContaining({ + status: "changed", + modelOptions: [{ id: "reasoning", value: "on" }], + value: "on", + }), + ); + expect( + transition({ + capabilities: cursorCaps, + modelOptions: [{ id: "reasoning", value: "off" }], + action: { type: "cycle", direction: "decrease" }, + }), + ).toEqual(expect.objectContaining({ value: "on" })); + }); + + it("resolves explicit, default, and missing current values", () => { + expect(transition({ modelOptions: [{ id: "reasoningEffort", value: "xhigh" }] })).toEqual( + expect.objectContaining({ value: "high" }), + ); + expect(transition({})).toEqual(expect.objectContaining({ value: "xhigh" })); + + const unresolvedCaps = createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ], + }); + expect(transition({ capabilities: unresolvedCaps })).toEqual( + expect.objectContaining({ value: "low" }), + ); + expect( + transition({ + capabilities: unresolvedCaps, + action: { type: "cycle", direction: "decrease" }, + }), + ).toEqual(expect.objectContaining({ value: "high" })); + }); + + it("cycles Claude ordinary effort and ultrathink without persisting prompt values", () => { + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "medium" }], + }), + ).toEqual( + expect.objectContaining({ + prompt: "", + modelOptions: [{ id: "effort", value: "high" }], + value: "high", + }), + ); + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + prompt: "keep this exactly", + }), + ).toEqual({ + status: "changed", + prompt: "Ultrathink:\nkeep this exactly", + modelOptions: [{ id: "effort", value: "high" }], + value: "ultrathink", + label: "Ultrathink", + }); + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + prompt: "Ultrathink:\nkeep this exactly", + }), + ).toEqual({ + status: "changed", + prompt: "keep this exactly", + modelOptions: [{ id: "effort", value: "medium" }], + value: "medium", + label: "Medium", + }); + }); + + it("supports Claude's persisted ultracode option as an ordinary effort", () => { + const caps = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High" }, + { id: "ultracode", label: "Ultracode" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], + }, + ], + }); + + expect( + transition({ capabilities: caps, modelOptions: [{ id: "effort", value: "high" }] }), + ).toEqual( + expect.objectContaining({ + modelOptions: [{ id: "effort", value: "ultracode" }], + value: "ultracode", + }), + ); + }); + + it("enters ultrathink from empty prompts and strips only its exact owned leading prefix", () => { + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + }), + ).toEqual(expect.objectContaining({ prompt: "Ultrathink:\n", value: "ultrathink" })); + expect( + transition({ + capabilities: claudeCaps, + prompt: "Ultrathink:\n\n preserved body ", + action: { type: "select", descriptorId: "effort", value: "medium" }, + }), + ).toEqual(expect.objectContaining({ prompt: "\n preserved body " })); + }); + + it("does not strip user-authored ultrathink labels when selecting ordinary effort", () => { + for (const prompt of ["ultrathink: explain the term", "Ultrathink: explain the term"]) { + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + prompt, + action: { type: "select", descriptorId: "effort", value: "medium" }, + }), + ).toEqual({ status: "blocked", reason: "ultrathink-in-prompt-body" }); + } + }); + + it("leaves Claude slash commands executable when ultrathink is selected", () => { + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + prompt: "/compact keep recent errors", + action: { type: "select", descriptorId: "effort", value: "ultrathink" }, + }), + ).toEqual({ status: "unchanged" }); + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + prompt: "/home/theo/app.ts crashed on load", + action: { type: "select", descriptorId: "effort", value: "ultrathink" }, + }), + ).toEqual( + expect.objectContaining({ + prompt: "Ultrathink:\n/home/theo/app.ts crashed on load", + }), + ); + }); + + it("wraps Claude slash-command drafts across persistable effort levels", () => { + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "high" }], + prompt: "/compact", + }), + ).toEqual({ + status: "changed", + prompt: "/compact", + modelOptions: [{ id: "effort", value: "medium" }], + value: "medium", + label: "Medium", + }); + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "medium" }], + prompt: "/compact", + action: { type: "cycle", direction: "decrease" }, + }), + ).toEqual(expect.objectContaining({ prompt: "/compact", value: "high" })); + }); + + it("blocks leaving ultrathink when it occurs in the prompt body", () => { + const modelOptions = [{ id: "effort", value: "high" }] as const; + expect( + transition({ + capabilities: claudeCaps, + modelOptions, + prompt: "Explain ultrathink without changing my text", + action: { type: "select", descriptorId: "effort", value: "medium" }, + }), + ).toEqual({ status: "blocked", reason: "ultrathink-in-prompt-body" }); + expect( + transition({ + capabilities: claudeCaps, + modelOptions, + prompt: "Explain ultrathink without changing my text", + action: { type: "select", descriptorId: "effort", value: "ultrathink" }, + }), + ).toEqual({ status: "unchanged" }); + expect(modelOptions).toEqual([{ id: "effort", value: "high" }]); + }); + + it("removes stale prompt-injected values from persisted selections", () => { + expect( + transition({ + capabilities: claudeCaps, + modelOptions: [{ id: "effort", value: "ultrathink" }], + prompt: "Ultrathink:\nkeep this", + action: { type: "select", descriptorId: "effort", value: "high" }, + }), + ).toEqual( + expect.objectContaining({ + prompt: "keep this", + modelOptions: [{ id: "effort", value: "high" }], + }), + ); + }); + + it("does not persist unknown future prompt-injected values", () => { + const capabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High" }, + { id: "future-injected", label: "Future" }, + ], + currentValue: "high", + promptInjectedValues: ["future-injected"], + }, + ], + }); + + expect(transition({ capabilities, prompt: "keep this" })).toEqual({ + status: "unsupported", + reason: "unsupported-prompt-injected-value", + }); + }); + + it("preserves unrelated and unknown selections in their original order", () => { + const modelOptions = [ + { id: "future", value: "unchanged" }, + { id: "effort", value: "medium" }, + { id: "thinking", value: true }, + { id: "contextWindow", value: "200k" }, + ] as const; + const result = transition({ capabilities: claudeCaps, modelOptions }); + + expect(result).toEqual( + expect.objectContaining({ + modelOptions: [ + { id: "future", value: "unchanged" }, + { id: "effort", value: "high" }, + { id: "thinking", value: true }, + { id: "contextWindow", value: "200k" }, + ], + }), + ); + expect(modelOptions).toEqual([ + { id: "future", value: "unchanged" }, + { id: "effort", value: "medium" }, + { id: "thinking", value: true }, + { id: "contextWindow", value: "200k" }, + ]); + }); + + it("does not infer OpenCode variants or models without reasoning capabilities", () => { + const openCodeCaps = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Variant", + type: "select", + options: [{ id: "fast", label: "Fast" }], + }, + ], + }); + expect(transition({ capabilities: openCodeCaps })).toEqual({ + status: "unsupported", + reason: "missing-reasoning-option", + }); + expect( + transition({ capabilities: createModelCapabilities({ optionDescriptors: [] }) }), + ).toEqual({ status: "unsupported", reason: "missing-reasoning-option" }); + }); + + it("handles no choices, one choice, and stale direct selections", () => { + const emptyCaps = createModelCapabilities({ + optionDescriptors: [{ id: "reasoning", label: "Reasoning", type: "select", options: [] }], + }); + expect(transition({ capabilities: emptyCaps })).toEqual({ + status: "unsupported", + reason: "missing-choices", + }); + + const oneChoiceCaps = createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoning", + label: "Reasoning", + type: "select", + options: [{ id: "on", label: "On", isDefault: true }], + }, + ], + }); + expect(transition({ capabilities: oneChoiceCaps })).toEqual({ status: "unchanged" }); + expect( + transition({ + capabilities: oneChoiceCaps, + action: { type: "select", descriptorId: "reasoning", value: "missing" }, + }), + ).toEqual({ status: "unsupported", reason: "unknown-value" }); + }); + + it("replaces a stale persisted value when selecting the effective default", () => { + expect( + transition({ + modelOptions: [{ id: "reasoningEffort", value: "removed-level" }], + action: { type: "select", descriptorId: "reasoningEffort", value: "high" }, + }), + ).toEqual({ + status: "changed", + prompt: "", + modelOptions: [{ id: "reasoningEffort", value: "high" }], + value: "high", + label: "High", + }); + }); + + it("makes direct selection and cycling to the same target identical", () => { + const modelOptions = [{ id: "reasoningEffort", value: "xhigh" }] as const; + const cycled = transition({ modelOptions }); + const selected = transition({ + modelOptions, + action: { type: "select", descriptorId: "reasoningEffort", value: "high" }, + }); + expect(selected).toEqual(cycled); + }); +}); + +describe("stripClaudeUltrathinkPrefix", () => { + it("strips only the exact app-owned prefix", () => { + expect(stripClaudeUltrathinkPrefix("Ultrathink:\nInvestigate")).toBe("Investigate"); + expect(stripClaudeUltrathinkPrefix("Ultrathink: explain the term")).toBe( + "Ultrathink: explain the term", + ); + expect(stripClaudeUltrathinkPrefix("ultrathink:\nInvestigate")).toBe( + "ultrathink:\nInvestigate", + ); + }); +}); + describe("applyClaudePromptEffortPrefix", () => { it("keeps slash commands intact when ultrathink is selected", () => { expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b3c278a1ac36..5e8bceea7eef 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -225,6 +225,217 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole return typeof text === "string" && /\bultrathink\b/i.test(text); } +export function stripClaudeUltrathinkPrefix(text: string): string { + return text.startsWith("Ultrathink:\n") ? text.slice("Ultrathink:\n".length) : text; +} + +export type ReasoningTransitionAction = + | { type: "cycle"; direction: "increase" | "decrease" } + | { type: "select"; descriptorId: string; value: string }; + +export type ReasoningTransitionResult = + | { + status: "changed"; + prompt: string; + modelOptions: ReadonlyArray | undefined; + value: string; + label: string; + } + | { status: "unchanged" } + | { status: "blocked"; reason: "ultrathink-in-prompt-body" } + | { + status: "unsupported"; + reason: + | "missing-reasoning-option" + | "missing-choices" + | "unsupported-prompt-injected-value" + | "unknown-value"; + }; + +const REASONING_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning"]); + +export function isReasoningDescriptorId(descriptorId: string): boolean { + return REASONING_DESCRIPTOR_IDS.has(descriptorId); +} +const ULTRATHINK_VALUE = "ultrathink"; +const ULTRATHINK_PREFIX = "Ultrathink:\n"; +const CLAUDE_SLASH_COMMAND = /^\/[^\s/]+(?:\s|$)/u; + +function isClaudeSlashCommandPrompt(prompt: string): boolean { + return CLAUDE_SLASH_COMMAND.test(prompt.trim()); +} + +function findReasoningDescriptor( + capabilities: ModelCapabilities, +): Extract | undefined { + return (capabilities.optionDescriptors ?? []).find( + (descriptor): descriptor is Extract => + descriptor.type === "select" && REASONING_DESCRIPTOR_IDS.has(descriptor.id), + ); +} + +function providerSelectionsEqual( + left: ReadonlyArray | undefined, + right: ReadonlyArray | undefined, +): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + return left.every( + (selection, index) => + selection.id === right[index]?.id && selection.value === right[index]?.value, + ); +} + +function resolvePersistedReasoningValue( + descriptor: Extract, + modelOptions: ReadonlyArray | null | undefined, +): string | undefined { + const selected = getProviderOptionStringSelectionValue(modelOptions, descriptor.id); + const candidates = [ + selected, + descriptor.currentValue, + descriptor.options.find((option) => option.isDefault)?.id, + ]; + return candidates.find( + (candidate) => + candidate !== undefined && + descriptor.options.some((option) => option.id === candidate) && + !descriptor.promptInjectedValues?.includes(candidate), + ); +} + +function updateReasoningSelection( + modelOptions: ReadonlyArray | null | undefined, + descriptorId: string, + value: string, +): ReadonlyArray { + const current = modelOptions ?? []; + const index = current.findIndex((selection) => selection.id === descriptorId); + if (index === -1) { + return [...current, { id: descriptorId, value }]; + } + if (current[index]?.value === value) { + return current; + } + return current.map((selection, selectionIndex) => + selectionIndex === index ? { ...selection, value } : selection, + ); +} + +function removePromptInjectedReasoningSelection( + modelOptions: ReadonlyArray | null | undefined, + descriptor: Extract, +): ReadonlyArray | undefined { + if (!modelOptions) return undefined; + const selection = modelOptions.find((candidate) => candidate.id === descriptor.id); + if ( + typeof selection?.value !== "string" || + !descriptor.promptInjectedValues?.includes(selection.value) + ) { + return modelOptions; + } + const next = modelOptions.filter((candidate) => candidate !== selection); + return next.length > 0 ? next : undefined; +} + +export function resolveReasoningTransition(input: { + capabilities: ModelCapabilities; + modelOptions?: ReadonlyArray | null | undefined; + prompt: string; + action: ReasoningTransitionAction; +}): ReasoningTransitionResult { + const { capabilities, modelOptions, prompt, action } = input; + const descriptor = findReasoningDescriptor(capabilities); + if (!descriptor || (action.type === "select" && action.descriptorId !== descriptor.id)) { + return { status: "unsupported", reason: "missing-reasoning-option" }; + } + if (descriptor.options.length === 0) { + return { status: "unsupported", reason: "missing-choices" }; + } + + const promptInjectedUltrathink = + descriptor.promptInjectedValues?.includes(ULTRATHINK_VALUE) ?? false; + const promptBody = promptInjectedUltrathink ? stripClaudeUltrathinkPrefix(prompt) : prompt; + const hasLeadingPrefix = promptBody !== prompt; + const ultrathinkInBody = promptInjectedUltrathink && isClaudeUltrathinkPrompt(promptBody); + const promptControlsUltrathink = + promptInjectedUltrathink && (hasLeadingPrefix || ultrathinkInBody); + const persistedValue = resolvePersistedReasoningValue(descriptor, modelOptions); + const rawPersistedValue = getProviderOptionStringSelectionValue(modelOptions, descriptor.id); + const currentValue = promptControlsUltrathink ? ULTRATHINK_VALUE : persistedValue; + const cycleOptions = isClaudeSlashCommandPrompt(prompt) + ? descriptor.options.filter((option) => !descriptor.promptInjectedValues?.includes(option.id)) + : descriptor.options; + + let targetValue: string; + if (action.type === "select") { + if (!descriptor.options.some((option) => option.id === action.value)) { + return { status: "unsupported", reason: "unknown-value" }; + } + targetValue = action.value; + } else if (currentValue === undefined) { + if (cycleOptions.length === 0) { + return { status: "unchanged" }; + } + const fallbackIndex = action.direction === "increase" ? 0 : cycleOptions.length - 1; + targetValue = cycleOptions[fallbackIndex]?.id ?? ""; + } else { + if (cycleOptions.length === 0) { + return { status: "unchanged" }; + } + const currentIndex = cycleOptions.findIndex((option) => option.id === currentValue); + const offset = action.direction === "increase" ? 1 : -1; + const targetIndex = + currentIndex === -1 + ? action.direction === "increase" + ? 0 + : cycleOptions.length - 1 + : (currentIndex + offset + cycleOptions.length) % cycleOptions.length; + targetValue = cycleOptions[targetIndex]?.id ?? ""; + } + + if (ultrathinkInBody && targetValue !== ULTRATHINK_VALUE) { + return { status: "blocked", reason: "ultrathink-in-prompt-body" }; + } + + const targetOption = descriptor.options.find((option) => option.id === targetValue); + if (!targetOption) { + return { status: "unsupported", reason: "unknown-value" }; + } + + const targetIsPromptInjected = descriptor.promptInjectedValues?.includes(targetValue) ?? false; + if (targetIsPromptInjected && targetValue !== ULTRATHINK_VALUE) { + return { status: "unsupported", reason: "unsupported-prompt-injected-value" }; + } + const nextPrompt = targetIsPromptInjected + ? hasLeadingPrefix || ultrathinkInBody || isClaudeSlashCommandPrompt(prompt) + ? prompt + : `${ULTRATHINK_PREFIX}${prompt}` + : hasLeadingPrefix + ? promptBody + : prompt; + // Leave an implicit default implicit; any stored value (including a stale + // or prompt-injected one) is rewritten to the target. + const nextModelOptions = targetIsPromptInjected + ? removePromptInjectedReasoningSelection(modelOptions, descriptor) + : rawPersistedValue === undefined && targetValue === persistedValue + ? (modelOptions ?? undefined) + : updateReasoningSelection(modelOptions, descriptor.id, targetValue); + const normalizedInputOptions = modelOptions ?? undefined; + + if (nextPrompt === prompt && providerSelectionsEqual(nextModelOptions, normalizedInputOptions)) { + return { status: "unchanged" }; + } + + return { + status: "changed", + prompt: nextPrompt, + modelOptions: nextModelOptions, + value: targetValue, + label: targetOption.label, + }; +} + /** Compare Codex model families without changing provider-owned dispatch identifiers. */ export function codexModelFamily(slug: string): string { return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug; @@ -421,7 +632,7 @@ export function applyClaudePromptEffortPrefix( // runs it. Command names come from arbitrary file names ("/deploy.prod", // "/plugin:skill"), so accept any first token without a second slash; // absolute paths like "/home/theo/app.ts" keep the prefix. - if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) { + if (effort !== "ultrathink" || isClaudeSlashCommandPrompt(trimmed)) { return trimmed; } if (trimmed.startsWith("Ultrathink:")) {