From 2a12f0b977f706829eafaa01603d82608f8223f3 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sun, 16 Aug 2026 16:54:06 +0200 Subject: [PATCH 01/20] feat(web): add reasoning cycle keybindings --- apps/server/src/keybindings.test.ts | 2 + apps/web/src/components/chat/ChatComposer.tsx | 70 +++- .../src/components/chat/TraitsPicker.test.ts | 53 ++- apps/web/src/components/chat/TraitsPicker.tsx | 59 ++- .../KeybindingsSettings.logic.test.ts | 33 +- .../settings/KeybindingsSettings.logic.ts | 12 +- apps/web/src/keybindings.test.ts | 89 +++++ apps/web/src/keybindings.ts | 10 + docs/user/keybindings.md | 11 + packages/contracts/src/keybindings.test.ts | 12 + packages/contracts/src/keybindings.ts | 2 + packages/shared/src/keybindings.ts | 2 + packages/shared/src/model.test.ts | 363 +++++++++++++++++- packages/shared/src/model.ts | 195 ++++++++++ 14 files changed, 886 insertions(+), 27 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 24a137d933fa..0e13b63fbe40 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -207,6 +207,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { 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"), "alt+,"); + assert.equal(defaultsByCommand.get("reasoning.increase"), "alt+."); }), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 07a9afecbd9f..1fbf3ffe0119 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -20,7 +20,11 @@ import { } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { + createModelSelection, + normalizeModelSlug, + resolveReasoningTransition, +} from "@t3tools/shared/model"; import { memo, type ReactNode, @@ -67,7 +71,7 @@ import { ComposerStashMenu } from "./ComposerStashMenu"; import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; -import { resolveShortcutCommand } from "../../keybindings"; +import { reasoningCycleDirectionFromCommand, resolveShortcutCommand } from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, @@ -211,7 +215,10 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderInteractionModeToggle } from "../../providerModels"; +import { + getProviderInteractionModeToggle, + getProviderModelCapabilities, +} from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -691,6 +698,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -2291,9 +2299,10 @@ 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 ( @@ -2305,17 +2314,66 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } + if (reasoningDirection !== null) { + const currentModelOptions = composerModelOptions?.[selectedInstanceId]; + const transition = resolveReasoningTransition({ + capabilities: getProviderModelCapabilities( + selectedProviderModels, + selectedModel, + selectedProvider, + ), + modelOptions: currentModelOptions, + prompt: promptRef.current, + action: { type: "cycle", direction: reasoningDirection }, + }); + if (transition.status === "changed") { + if (transition.prompt !== promptRef.current) { + setPromptFromTraits(transition.prompt); + } + if (transition.modelOptions !== currentModelOptions) { + setProviderModelOptions( + composerDraftTarget, + selectedProvider, + transition.modelOptions, + { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }, + ); + } + } else if (transition.status === "blocked") { + toastManager.add({ + type: "info", + title: "Remove “ultrathink” from the prompt text to change reasoning.", + }); + } else if (transition.status === "unsupported") { + toastManager.add({ + type: "info", + title: "This model does not advertise reasoning levels.", + }); + } + return; + } void stashCurrentPrompt(); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [ activePendingProgress, + composerDraftTarget, + composerModelOptions, isComposerApprovalState, isComposerModelPickerOpen, keybindings, pendingUserInputs.length, projectSelectionRequired, + selectedInstanceId, + selectedModel, + selectedProvider, + selectedProviderModels, + setPromptFromTraits, + setProviderModelOptions, stashCurrentPrompt, terminalOpen, ]); diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index b457f515ff77..d6d9e3e95d28 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; -import { buildTraitsTriggerDisplay } from "./TraitsPicker"; +import { buildTraitsTriggerDisplay, resolveTraitsSelectChange } from "./TraitsPicker"; function selectDescriptor( id: string, @@ -130,3 +130,54 @@ describe("buildTraitsTriggerDisplay", () => { ).toEqual({ label: "Ultrathink", showFastModeIcon: true }); }); }); + +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" }, + ], + }); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 924fbddeab70..aea97cd3818e 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -7,12 +7,12 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { - applyClaudePromptEffortPrefix, buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, + resolveReasoningTransition, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -46,8 +46,6 @@ type TraitsPersistence = onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; -const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; - function DefaultBadge() { return ( ; + descriptors: ReadonlyArray; + descriptor: Extract; + value: string; + prompt: string; + modelOptions: ProviderOptions | null | undefined; +}): { prompt: string; modelOptions: ProviderOptions | undefined } | null { + const transition = resolveReasoningTransition({ + capabilities: input.caps, + modelOptions: input.modelOptions, + prompt: input.prompt, + action: { type: "select", descriptorId: input.descriptor.id, value: input.value }, + }); + if (transition.status === "changed") { + return { prompt: transition.prompt, modelOptions: transition.modelOptions }; + } + if (transition.status !== "not-applicable") { + return null; + } + + return { + prompt: input.prompt, + modelOptions: buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(input.descriptors, input.descriptor.id, input.value), + ), + }; +} + function getSelectedTraits( provider: ProviderDriverKind, models: ReadonlyArray, @@ -249,6 +276,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ [instanceId, model, persistence, provider, setProviderModelOptions], ); const { + caps, descriptors, selectDescriptors, booleanDescriptors, @@ -273,20 +301,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 90eaaef99413..6e9db54e62ed 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -52,18 +52,47 @@ describe("KeybindingsSettings.logic", () => { it("captures platform-specific mod shortcuts", () => { expect( keybindingFromKeyboardEvent( - { key: "K", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, + { key: "K", code: "KeyK", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, "MacIntel", ), ).toBe("mod+shift+k"); expect( keybindingFromKeyboardEvent( - { key: "K", metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, + { key: "K", code: "KeyK", metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, "Win32", ), ).toBe("mod+shift+k"); }); + 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/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 8c15111e1969..25672cadb08d 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -314,10 +314,18 @@ export function normalizeShortcutKeyToken(key: string): string | null { } export function keybindingFromKeyboardEvent( - event: Pick, + event: Pick, platform: string, ): string | null { - const keyToken = normalizeShortcutKeyToken(event.key); + const portableOptionKey = + isMacPlatform(platform) && event.altKey + ? event.code === "Comma" + ? "," + : event.code === "Period" + ? "." + : event.key + : event.key; + const keyToken = normalizeShortcutKeyToken(portableOptionKey); if (!keyToken) return null; const parts: string[] = []; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 6ac53a52f18a..c2df6e3ea8b4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -20,6 +20,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + reasoningCycleDirectionFromCommand, resolveShortcutCommand, shouldShowModelPickerJumpHints, shouldShowThreadJumpHints, @@ -58,6 +59,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 }; } @@ -140,6 +152,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: altShortcut(","), + command: "reasoning.decrease", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: altShortcut("."), + command: "reasoning.increase", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, @@ -453,6 +475,73 @@ 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 Option punctuation by physical key code", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "≤", code: "Comma", altKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "reasoning.decrease", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "≥", code: "Period", altKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "reasoning.increase", + ); + }); + + it("leaves terminal focus untouched and preserves later-binding precedence", () => { + assert.isNull( + resolveShortcutCommand(event({ key: ",", code: "Comma", 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 6ec9a6fab858..3a8a66b8a5fb 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -52,6 +52,7 @@ const TERMINAL_DELETE_TO_LINE_START = "\u0015"; const EVENT_CODE_KEY_ALIASES: Readonly> = { BracketLeft: ["["], BracketRight: ["]"], + Comma: [","], Digit0: ["0"], Digit1: ["1"], Digit2: ["2"], @@ -62,6 +63,7 @@ const EVENT_CODE_KEY_ALIASES: Readonly> = { Digit7: ["7"], Digit8: ["8"], Digit9: ["9"], + Period: ["."], }; function normalizeEventKey(key: string): string { @@ -288,6 +290,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 shouldShowThreadJumpHints( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 8e56a79a287d..2ba00a8bedd7 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,6 +52,17 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. +`reasoning.decrease` and `reasoning.increase` cycle through the selected model's available +reasoning levels, wrapping at either end. They default to `alt+,` and `alt+.` and follow the order +advertised by the model. If an agent is running, the change applies to the next message; it does +not alter work already in progress. Models and providers that do not advertise ordered reasoning +levels are left unchanged. + +For Claude models that offer Ultrathink, selecting it adds a leading `Ultrathink:` control prefix +to the draft, and selecting another level removes that prefix while preserving the rest of the +draft. If `ultrathink` appears elsewhere in the draft text, remove it before changing levels so T3 +Code does not rewrite text it does not own. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 71d8624a8aea..9ef56b2bea17 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -107,6 +107,18 @@ it.effect("parses keybinding rules", () => command: "thread.previous", }); 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"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 19276c41e7b6..1c9b196828c0 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -68,6 +68,8 @@ export const STATIC_KEYBINDING_COMMANDS = [ "projectSearch.toggle", "themeEditor.toggle", "composer.stash", + "reasoning.decrease", + "reasoning.increase", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac9..a86f38c8de76 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -39,6 +39,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, + { key: "alt+,", command: "reasoning.decrease", when: "!terminalFocus" }, + { key: "alt+.", command: "reasoning.increase", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index b67c45744734..bc40ffb3de4e 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ModelCapabilities, + type ProviderOptionSelection, +} from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, @@ -12,6 +17,7 @@ import { getProviderOptionStringSelectionValue, normalizeCustomModelSlug, normalizeModelSlug, + resolveReasoningTransition, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -155,3 +161,358 @@ describe("model slug normalization", () => { expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); }); }); + +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 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:\r\n\n preserved body ", + action: { type: "select", descriptorId: "effort", value: "medium" }, + }), + ).toEqual(expect.objectContaining({ prompt: "\n preserved body " })); + }); + + 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: "invalid", reason: "unknown-value" }); + }); + + 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); + }); + + it("returns not-applicable for non-reasoning mouse selections", () => { + expect( + transition({ + capabilities: claudeCaps, + action: { type: "select", descriptorId: "contextWindow", value: "200k" }, + }), + ).toEqual({ status: "not-applicable" }); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bdc0c0cc8efb..c6e7e0f96b7a 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -232,6 +232,201 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole return typeof text === "string" && /\bultrathink\b/i.test(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"; + } + | { status: "not-applicable" } + | { status: "invalid"; reason: "unknown-value" }; + +const REASONING_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning"]); +const ULTRATHINK_VALUE = "ultrathink"; +const ULTRATHINK_PREFIX = "Ultrathink:\n"; +const LEADING_ULTRATHINK_PREFIX = /^Ultrathink:(?:\r?\n)?/i; + +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; + if (action.type === "select" && !REASONING_DESCRIPTOR_IDS.has(action.descriptorId)) { + return { status: "not-applicable" }; + } + + 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 leadingPrefixMatch = promptInjectedUltrathink + ? prompt.match(LEADING_ULTRATHINK_PREFIX) + : null; + const promptBody = leadingPrefixMatch ? prompt.slice(leadingPrefixMatch[0].length) : prompt; + const ultrathinkInBody = promptInjectedUltrathink && isClaudeUltrathinkPrompt(promptBody); + const promptControlsUltrathink = + promptInjectedUltrathink && (leadingPrefixMatch !== null || ultrathinkInBody); + const persistedValue = resolvePersistedReasoningValue(descriptor, modelOptions); + const rawPersistedValue = getProviderOptionStringSelectionValue(modelOptions, descriptor.id); + const persistedPromptInjectedValue = + rawPersistedValue !== undefined && + (descriptor.promptInjectedValues?.includes(rawPersistedValue) ?? false); + const currentValue = promptControlsUltrathink ? ULTRATHINK_VALUE : persistedValue; + + let targetValue: string; + if (action.type === "select") { + if (!descriptor.options.some((option) => option.id === action.value)) { + return { status: "invalid", reason: "unknown-value" }; + } + targetValue = action.value; + } else if (currentValue === undefined) { + const fallbackIndex = action.direction === "increase" ? 0 : descriptor.options.length - 1; + targetValue = descriptor.options[fallbackIndex]?.id ?? ""; + } else { + const currentIndex = descriptor.options.findIndex((option) => option.id === currentValue); + const offset = action.direction === "increase" ? 1 : -1; + const targetIndex = + currentIndex === -1 + ? action.direction === "increase" + ? 0 + : descriptor.options.length - 1 + : (currentIndex + offset + descriptor.options.length) % descriptor.options.length; + targetValue = descriptor.options[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: "invalid", 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 + ? leadingPrefixMatch || ultrathinkInBody + ? prompt + : `${ULTRATHINK_PREFIX}${prompt}` + : leadingPrefixMatch + ? promptBody + : prompt; + const nextModelOptions = targetIsPromptInjected + ? removePromptInjectedReasoningSelection(modelOptions, descriptor) + : targetValue === persistedValue && !persistedPromptInjectedValue + ? (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, + }; +} + export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, From f2d5521cdd6d09e5137df3e8ccbed302feffa953 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:16:10 +0200 Subject: [PATCH 02/20] fix(web): preserve composer state when changing reasoning --- apps/web/src/components/chat/ChatComposer.tsx | 38 +++++++++++++------ apps/web/src/composer-logic.test.ts | 25 ++++++++++++ apps/web/src/composer-logic.ts | 16 ++++++++ packages/shared/src/model.test.ts | 23 +++++++++++ packages/shared/src/model.ts | 9 ++++- 5 files changed, 98 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 748657396b11..b2bb342c7ea1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -46,6 +46,7 @@ import { composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, + mapComposerCursorAcrossLeadingPromptChange, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; @@ -2564,14 +2565,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isCommandPaletteOpen()) { return; } - if (pendingUserInputs.length > 0 && !isComposerApprovalState) { - setIsStashMenuOpen((open) => !open); - return; - } - if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) { - return; - } if (reasoningDirection !== null) { + const currentPrompt = promptRef.current; const currentModelOptions = composerModelOptions?.[selectedInstanceId]; const transition = resolveReasoningTransition({ capabilities: getProviderModelCapabilities( @@ -2580,12 +2575,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProvider, ), modelOptions: currentModelOptions, - prompt: promptRef.current, + prompt: currentPrompt, action: { type: "cycle", direction: reasoningDirection }, }); if (transition.status === "changed") { - if (transition.prompt !== promptRef.current) { - setPromptFromTraits(transition.prompt); + if (transition.prompt !== currentPrompt) { + const currentExpandedCursor = + composerEditorRef.current?.readSnapshot().expandedCursor ?? + expandCollapsedComposerCursor(currentPrompt, composerCursor); + const nextExpandedCursor = mapComposerCursorAcrossLeadingPromptChange( + currentPrompt, + transition.prompt, + currentExpandedCursor, + ); + promptRef.current = transition.prompt; + setPrompt(transition.prompt); + setComposerCursor( + collapseExpandedComposerCursor(transition.prompt, nextExpandedCursor), + ); + setComposerTrigger(detectComposerTrigger(transition.prompt, nextExpandedCursor)); } if (transition.modelOptions !== currentModelOptions) { setProviderModelOptions( @@ -2612,6 +2620,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (pendingUserInputs.length > 0 && !isComposerApprovalState) { + setIsStashMenuOpen((open) => !open); + return; + } + if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) { + return; + } void stashCurrentPrompt(); }; window.addEventListener("keydown", handler, true); @@ -2619,6 +2634,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ activePendingProgress, composerDraftTarget, + composerCursor, composerModelOptions, isComposerApprovalState, isComposerModelPickerOpen, @@ -2629,7 +2645,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProvider, selectedProviderModels, - setPromptFromTraits, + setPrompt, setProviderModelOptions, stashCurrentPrompt, terminalOpen, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 80b74f926454..d572b1ca8341 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -7,11 +7,36 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, + mapComposerCursorAcrossLeadingPromptChange, parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +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("submits plain Enter on desktop", () => { expect( diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 239d6c619655..b444ce3f199f 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -37,6 +37,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 === " " || diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index bc082486cf50..34f8e003b075 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -363,6 +363,29 @@ describe("resolveReasoningTransition", () => { ).toEqual(expect.objectContaining({ prompt: "\n preserved 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("blocks leaving ultrathink when it occurs in the prompt body", () => { const modelOptions = [{ id: "effort", value: "high" }] as const; expect( diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index ff2f14834937..d3180a798899 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -257,6 +257,11 @@ const REASONING_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasonin const ULTRATHINK_VALUE = "ultrathink"; const ULTRATHINK_PREFIX = "Ultrathink:\n"; const LEADING_ULTRATHINK_PREFIX = /^Ultrathink:(?:\r?\n)?/i; +const CLAUDE_SLASH_COMMAND = /^\/[^\s/]+(?:\s|$)/u; + +function isClaudeSlashCommandPrompt(prompt: string): boolean { + return CLAUDE_SLASH_COMMAND.test(prompt.trim()); +} function findReasoningDescriptor( capabilities: ModelCapabilities, @@ -401,7 +406,7 @@ export function resolveReasoningTransition(input: { return { status: "unsupported", reason: "unsupported-prompt-injected-value" }; } const nextPrompt = targetIsPromptInjected - ? leadingPrefixMatch || ultrathinkInBody + ? leadingPrefixMatch || ultrathinkInBody || isClaudeSlashCommandPrompt(prompt) ? prompt : `${ULTRATHINK_PREFIX}${prompt}` : leadingPrefixMatch @@ -561,7 +566,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:")) { From a011c09e38e2e2d96163761a7f1af192c2ff5ef1 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:26:40 +0200 Subject: [PATCH 03/20] fix(web): keep reasoning cycles scoped to visible drafts --- apps/web/src/components/chat/ChatComposer.tsx | 6 ++++- packages/shared/src/model.test.ts | 24 +++++++++++++++++++ packages/shared/src/model.ts | 21 +++++++++++----- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b2bb342c7ea1..7e231a04e6b8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2566,6 +2566,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (reasoningDirection !== null) { + const editorShowsDraftPrompt = !isComposerApprovalState && activePendingProgress === null; const currentPrompt = promptRef.current; const currentModelOptions = composerModelOptions?.[selectedInstanceId]; const transition = resolveReasoningTransition({ @@ -2579,6 +2580,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) action: { type: "cycle", direction: reasoningDirection }, }); if (transition.status === "changed") { + if (transition.prompt !== currentPrompt && !editorShowsDraftPrompt) { + return; + } if (transition.prompt !== currentPrompt) { const currentExpandedCursor = composerEditorRef.current?.readSnapshot().expandedCursor ?? @@ -2607,7 +2611,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, ); } - } else if (transition.status === "blocked") { + } else if (transition.status === "blocked" && editorShowsDraftPrompt) { toastManager.add({ type: "info", title: "Remove “ultrathink” from the prompt text to change reasoning.", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 34f8e003b075..8700eb2325f4 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -386,6 +386,30 @@ describe("resolveReasoningTransition", () => { ); }); + 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( diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d3180a798899..2721d5448f57 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -370,6 +370,9 @@ export function resolveReasoningTransition(input: { rawPersistedValue !== undefined && (descriptor.promptInjectedValues?.includes(rawPersistedValue) ?? false); 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") { @@ -378,18 +381,24 @@ export function resolveReasoningTransition(input: { } targetValue = action.value; } else if (currentValue === undefined) { - const fallbackIndex = action.direction === "increase" ? 0 : descriptor.options.length - 1; - targetValue = descriptor.options[fallbackIndex]?.id ?? ""; + if (cycleOptions.length === 0) { + return { status: "unchanged" }; + } + const fallbackIndex = action.direction === "increase" ? 0 : cycleOptions.length - 1; + targetValue = cycleOptions[fallbackIndex]?.id ?? ""; } else { - const currentIndex = descriptor.options.findIndex((option) => option.id === currentValue); + 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 - : descriptor.options.length - 1 - : (currentIndex + offset + descriptor.options.length) % descriptor.options.length; - targetValue = descriptor.options[targetIndex]?.id ?? ""; + : cycleOptions.length - 1 + : (currentIndex + offset + cycleOptions.length) % cycleOptions.length; + targetValue = cycleOptions[targetIndex]?.id ?? ""; } if (ultrathinkInBody && targetValue !== ULTRATHINK_VALUE) { From 60199311608275af0aeeb553fc725b84ab0c1cc0 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:30:47 +0200 Subject: [PATCH 04/20] fix(shared): replace stale reasoning selections --- packages/shared/src/model.test.ts | 15 +++++++++++++++ packages/shared/src/model.ts | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8700eb2325f4..541548b2f5e1 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -545,6 +545,21 @@ describe("resolveReasoningTransition", () => { ).toEqual({ status: "invalid", 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 }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2721d5448f57..56b6797dfb93 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -424,7 +424,9 @@ export function resolveReasoningTransition(input: { const nextModelOptions = targetIsPromptInjected ? removePromptInjectedReasoningSelection(modelOptions, descriptor) : targetValue === persistedValue && !persistedPromptInjectedValue - ? (modelOptions ?? undefined) + ? rawPersistedValue === undefined || rawPersistedValue === persistedValue + ? (modelOptions ?? undefined) + : updateReasoningSelection(modelOptions, descriptor.id, targetValue) : updateReasoningSelection(modelOptions, descriptor.id, targetValue); const normalizedInputOptions = modelOptions ?? undefined; From 5d5d7c3b48cd88eda267de3cacaf6a494f75ebdd Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:35:55 +0200 Subject: [PATCH 05/20] fix(shared): preserve user ultrathink labels --- packages/shared/src/model.test.ts | 17 +++++++++++++++-- packages/shared/src/model.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 541548b2f5e1..842df7fda762 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -347,7 +347,7 @@ describe("resolveReasoningTransition", () => { ); }); - it("enters ultrathink from empty prompts and strips only its owned leading prefix", () => { + it("enters ultrathink from empty prompts and strips only its exact owned leading prefix", () => { expect( transition({ capabilities: claudeCaps, @@ -357,12 +357,25 @@ describe("resolveReasoningTransition", () => { expect( transition({ capabilities: claudeCaps, - prompt: "ultrathink:\r\n\n preserved body ", + 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({ diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 56b6797dfb93..f2ac618a9563 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -256,7 +256,7 @@ export type ReasoningTransitionResult = const REASONING_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning"]); const ULTRATHINK_VALUE = "ultrathink"; const ULTRATHINK_PREFIX = "Ultrathink:\n"; -const LEADING_ULTRATHINK_PREFIX = /^Ultrathink:(?:\r?\n)?/i; +const LEADING_ULTRATHINK_PREFIX = /^Ultrathink:\n/; const CLAUDE_SLASH_COMMAND = /^\/[^\s/]+(?:\s|$)/u; function isClaudeSlashCommandPrompt(prompt: string): boolean { From 48147b48a99f808b23d763f65bf5014a89233d4b Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:38:14 +0200 Subject: [PATCH 06/20] fix(web): keep reasoning shortcuts on draft state --- apps/web/src/components/chat/ChatComposer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7e231a04e6b8..28ef20026f5b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2558,6 +2558,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const reasoningDirection = reasoningCycleDirectionFromCommand(command); if (command !== "composer.stash" && reasoningDirection === null) return; + if (reasoningDirection !== null && event.repeat) return; // Always claim recognized composer shortcuts, including in states where // the requested mutation is unavailable. event.preventDefault(); @@ -2567,7 +2568,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (reasoningDirection !== null) { const editorShowsDraftPrompt = !isComposerApprovalState && activePendingProgress === null; - const currentPrompt = promptRef.current; + const currentPrompt = editorShowsDraftPrompt + ? promptRef.current + : (getComposerDraft(composerDraftTarget)?.prompt ?? ""); const currentModelOptions = composerModelOptions?.[selectedInstanceId]; const transition = resolveReasoningTransition({ capabilities: getProviderModelCapabilities( @@ -2640,6 +2643,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerCursor, composerModelOptions, + getComposerDraft, isComposerApprovalState, isComposerModelPickerOpen, keybindings, From 95955d9dc1a0ac88aca3cab8407b44fdd628f4b5 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:41:59 +0200 Subject: [PATCH 07/20] fix(web): align ultrathink blocked state --- apps/web/src/components/chat/TraitsPicker.tsx | 3 ++- packages/shared/src/model.test.ts | 13 +++++++++++++ packages/shared/src/model.ts | 17 +++++++++-------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index fc43a6b3906c..0960d3a4df68 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -13,6 +13,7 @@ import { getProviderOptionDescriptors, isClaudeUltrathinkPrompt, resolveReasoningTransition, + stripClaudeUltrathinkPrefix, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -155,7 +156,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" diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 842df7fda762..d609cb0ca354 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -19,6 +19,7 @@ import { normalizeCustomModelSlug, normalizeModelSlug, resolveReasoningTransition, + stripClaudeUltrathinkPrefix, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -593,6 +594,18 @@ describe("resolveReasoningTransition", () => { }); }); +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 f2ac618a9563..babab4822a85 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -232,6 +232,10 @@ 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 }; @@ -256,7 +260,6 @@ export type ReasoningTransitionResult = const REASONING_DESCRIPTOR_IDS = new Set(["reasoningEffort", "effort", "reasoning"]); const ULTRATHINK_VALUE = "ultrathink"; const ULTRATHINK_PREFIX = "Ultrathink:\n"; -const LEADING_ULTRATHINK_PREFIX = /^Ultrathink:\n/; const CLAUDE_SLASH_COMMAND = /^\/[^\s/]+(?:\s|$)/u; function isClaudeSlashCommandPrompt(prompt: string): boolean { @@ -357,13 +360,11 @@ export function resolveReasoningTransition(input: { const promptInjectedUltrathink = descriptor.promptInjectedValues?.includes(ULTRATHINK_VALUE) ?? false; - const leadingPrefixMatch = promptInjectedUltrathink - ? prompt.match(LEADING_ULTRATHINK_PREFIX) - : null; - const promptBody = leadingPrefixMatch ? prompt.slice(leadingPrefixMatch[0].length) : prompt; + const promptBody = promptInjectedUltrathink ? stripClaudeUltrathinkPrefix(prompt) : prompt; + const hasLeadingPrefix = promptBody !== prompt; const ultrathinkInBody = promptInjectedUltrathink && isClaudeUltrathinkPrompt(promptBody); const promptControlsUltrathink = - promptInjectedUltrathink && (leadingPrefixMatch !== null || ultrathinkInBody); + promptInjectedUltrathink && (hasLeadingPrefix || ultrathinkInBody); const persistedValue = resolvePersistedReasoningValue(descriptor, modelOptions); const rawPersistedValue = getProviderOptionStringSelectionValue(modelOptions, descriptor.id); const persistedPromptInjectedValue = @@ -415,10 +416,10 @@ export function resolveReasoningTransition(input: { return { status: "unsupported", reason: "unsupported-prompt-injected-value" }; } const nextPrompt = targetIsPromptInjected - ? leadingPrefixMatch || ultrathinkInBody || isClaudeSlashCommandPrompt(prompt) + ? hasLeadingPrefix || ultrathinkInBody || isClaudeSlashCommandPrompt(prompt) ? prompt : `${ULTRATHINK_PREFIX}${prompt}` - : leadingPrefixMatch + : hasLeadingPrefix ? promptBody : prompt; const nextModelOptions = targetIsPromptInjected From 574e044ab52e7a4507782d5437bb38ec30bc1c60 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 20:50:49 +0200 Subject: [PATCH 08/20] fix(web): claim repeated reasoning shortcuts --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 28ef20026f5b..e7185e2ace2e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2558,11 +2558,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const reasoningDirection = reasoningCycleDirectionFromCommand(command); if (command !== "composer.stash" && reasoningDirection === null) return; - if (reasoningDirection !== null && event.repeat) 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()) { return; } From 0afb93e355fb47078f22e3040c99601eff9272a1 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 21:00:14 +0200 Subject: [PATCH 09/20] fix(keybindings): avoid printable reasoning shortcuts --- apps/server/src/keybindings.test.ts | 4 ++-- docs/user/keybindings.md | 2 +- packages/shared/src/keybindings.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 86cfe21bb43d..adf141808642 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -208,8 +208,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { 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"), "alt+,"); - assert.equal(defaultsByCommand.get("reasoning.increase"), "alt+."); + assert.equal(defaultsByCommand.get("reasoning.decrease"), "mod+alt+,"); + assert.equal(defaultsByCommand.get("reasoning.increase"), "mod+alt+."); }), ); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 28da9b9ab41d..68545d290603 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -53,7 +53,7 @@ successful pick; its hover glow and badge preview the element and color family t so add one in **Settings** → **Keybindings** if you want to use it. `reasoning.decrease` and `reasoning.increase` cycle through the selected model's available -reasoning levels, wrapping at either end. They default to `alt+,` and `alt+.` and follow the order +reasoning levels, wrapping at either end. They default to `mod+alt+,` and `mod+alt+.` and follow the order advertised by the model. If an agent is running, the change applies to the next message; it does not alter work already in progress. Models and providers that do not advertise ordered reasoning levels are left unchanged. diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b4ee3a1cf3de..94f1d312d23c 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -39,8 +39,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, - { key: "alt+,", command: "reasoning.decrease", when: "!terminalFocus" }, - { key: "alt+.", command: "reasoning.increase", when: "!terminalFocus" }, + { key: "mod+alt+,", command: "reasoning.decrease", when: "!terminalFocus" }, + { key: "mod+alt+.", command: "reasoning.increase", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, From 6525e3e9f6c2c5d42cc8e2edb3388eddb34dd933 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 4 Sep 2026 15:23:44 +0200 Subject: [PATCH 10/20] fix(web): restore composer draft after pending answers --- apps/web/src/components/chat/ChatComposer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6efd1c642059..0b9a601eed9d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2239,9 +2239,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Sync refs back to parent // ------------------------------------------------------------------ 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) return; promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + }, [activePendingProgress, prompt, promptRef]); useEffect(() => { if (composerSubmissionError === null) return; @@ -2351,6 +2355,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); From f3962bec9ca0483559a808e928e8c049a7d700af Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 4 Sep 2026 23:31:37 +0200 Subject: [PATCH 11/20] fix(web): preserve AltGr punctuation in shortcuts --- .../KeybindingsSettings.logic.test.ts | 31 +++++++ .../settings/KeybindingsSettings.logic.ts | 5 +- apps/web/src/keybindings.test.ts | 86 +++++++++++++++---- apps/web/src/keybindings.ts | 8 ++ 4 files changed, 114 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index d662fdf44716..8380aa4a4dc5 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -65,6 +65,37 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it("does not record AltGraph as a Ctrl+Alt shortcut", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "<", + code: "Comma", + metaKey: false, + ctrlKey: true, + altKey: true, + shiftKey: false, + getModifierState: (modifier) => modifier === "AltGraph", + }, + "Win32", + ), + ).toBeNull(); + expect( + keybindingFromKeyboardEvent( + { + key: ",", + code: "Comma", + metaKey: false, + ctrlKey: true, + altKey: true, + shiftKey: false, + getModifierState: () => false, + }, + "Win32", + ), + ).toBe("mod+alt+,"); + }); + it("records macOS Option punctuation using portable key names", () => { expect( keybindingFromKeyboardEvent( diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 84c12276a84b..60e4e9f7e1f5 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -11,6 +11,7 @@ import { parseKeybindingWhenExpression, } from "@t3tools/shared/keybindings"; +import { isAltGraphShortcutEvent } from "../../keybindings"; import { isMacPlatform } from "../../lib/utils"; export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -322,9 +323,11 @@ export function normalizeShortcutKeyToken(key: string): string | null { } export function keybindingFromKeyboardEvent( - event: Pick, + event: Pick & + Partial>, platform: string, ): string | null { + if (isAltGraphShortcutEvent(event)) return null; const portableOptionKey = isMacPlatform(platform) && event.altKey ? event.code === "Comma" diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d75c06d1ba07..380ce9c9e2e7 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -158,12 +158,12 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { - shortcut: altShortcut(","), + shortcut: modShortcut(",", { altKey: true }), command: "reasoning.decrease", whenAst: whenNot(whenIdentifier("terminalFocus")), }, { - shortcut: altShortcut("."), + shortcut: modShortcut(".", { altKey: true }), command: "reasoning.increase", whenAst: whenNot(whenIdentifier("terminalFocus")), }, @@ -560,29 +560,85 @@ describe("reasoning cycle shortcuts", () => { assert.isNull(reasoningCycleDirectionFromCommand(null)); }); - it("matches macOS Option punctuation by physical key code", () => { + it("matches macOS Mod+Option punctuation by physical key code", () => { assert.strictEqual( - resolveShortcutCommand(event({ key: "≤", code: "Comma", altKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), + resolveShortcutCommand( + event({ key: "≤", code: "Comma", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), "reasoning.decrease", ); assert.strictEqual( - resolveShortcutCommand(event({ key: "≥", code: "Period", altKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), + resolveShortcutCommand( + event({ key: "≥", code: "Period", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), "reasoning.increase", ); }); + it("ignores AltGraph punctuation without blocking intentional Ctrl+Alt shortcuts", () => { + 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: () => false, + }), + 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", altKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: true }, - }), + resolveShortcutCommand( + event({ key: ",", code: "Comma", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: true }, + }, + ), ); const overridden = compile([ diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 5d560eeba21e..1832ce7599e4 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -18,6 +18,7 @@ export interface ShortcutEventLike { ctrlKey: boolean; shiftKey: boolean; altKey: boolean; + getModifierState?(keyArg: string): boolean; } export interface ShortcutModifierStateLike { @@ -72,6 +73,12 @@ function normalizeEventKey(key: string): string { return normalized; } +export function isAltGraphShortcutEvent( + event: Pick, +): boolean { + return event.getModifierState?.("AltGraph") === true; +} + function resolveEventKeys(event: ShortcutEventLike): Set { const layoutKey = normalizeEventKey(event.key); const keys = new Set([layoutKey]); @@ -211,6 +218,7 @@ export function resolveShortcutCommand( keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, ): KeybindingCommand | null { + if (isAltGraphShortcutEvent(event)) return null; const platform = resolvePlatform(options); const context = resolveContext(options); From 7869147e596eb106639f64888b2c66259bef0e31 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 4 Sep 2026 23:37:07 +0200 Subject: [PATCH 12/20] fix(web): keep macOS option shortcuts detectable --- .../settings/KeybindingsSettings.logic.test.ts | 2 ++ .../settings/KeybindingsSettings.logic.ts | 2 +- apps/web/src/keybindings.test.ts | 16 ++++++++++++++-- apps/web/src/keybindings.ts | 6 ++++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 8380aa4a4dc5..465b87b151b5 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -106,6 +106,7 @@ describe("KeybindingsSettings.logic", () => { ctrlKey: false, altKey: true, shiftKey: false, + getModifierState: (modifier) => modifier === "AltGraph", }, "MacIntel", ), @@ -119,6 +120,7 @@ describe("KeybindingsSettings.logic", () => { ctrlKey: false, altKey: true, shiftKey: false, + getModifierState: (modifier) => modifier === "AltGraph", }, "MacIntel", ), diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 60e4e9f7e1f5..5694ce2cef80 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -327,7 +327,7 @@ export function keybindingFromKeyboardEvent( Partial>, platform: string, ): string | null { - if (isAltGraphShortcutEvent(event)) return null; + if (isAltGraphShortcutEvent(event, platform)) return null; const portableOptionKey = isMacPlatform(platform) && event.altKey ? event.code === "Comma" diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 380ce9c9e2e7..5305fcaf8c22 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -563,7 +563,13 @@ describe("reasoning cycle shortcuts", () => { it("matches macOS Mod+Option punctuation by physical key code", () => { assert.strictEqual( resolveShortcutCommand( - event({ key: "≤", code: "Comma", metaKey: true, altKey: true }), + event({ + key: "≤", + code: "Comma", + metaKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + }), DEFAULT_BINDINGS, { platform: "MacIntel", @@ -574,7 +580,13 @@ describe("reasoning cycle shortcuts", () => { ); assert.strictEqual( resolveShortcutCommand( - event({ key: "≥", code: "Period", metaKey: true, altKey: true }), + event({ + key: "≥", + code: "Period", + metaKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + }), DEFAULT_BINDINGS, { platform: "MacIntel", diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 1832ce7599e4..0e7d83c927a0 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -75,8 +75,10 @@ function normalizeEventKey(key: string): string { export function isAltGraphShortcutEvent( event: Pick, + platform: string, ): boolean { - return event.getModifierState?.("AltGraph") === true; + // Firefox reports ordinary Option presses as AltGraph on macOS. + return !isMacPlatform(platform) && event.getModifierState?.("AltGraph") === true; } function resolveEventKeys(event: ShortcutEventLike): Set { @@ -218,8 +220,8 @@ export function resolveShortcutCommand( keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, ): KeybindingCommand | null { - if (isAltGraphShortcutEvent(event)) return null; const platform = resolvePlatform(options); + if (isAltGraphShortcutEvent(event, platform)) return null; const context = resolveContext(options); for (let index = keybindings.length - 1; index >= 0; index -= 1) { From 29afea02ceacaaa1e424235fe2a97d1a41cc8030 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 4 Sep 2026 23:43:39 +0200 Subject: [PATCH 13/20] fix(web): match AltGr by layout key --- .../KeybindingsSettings.logic.test.ts | 33 ------------------- .../settings/KeybindingsSettings.logic.ts | 5 +-- apps/web/src/keybindings.test.ts | 4 +-- apps/web/src/keybindings.ts | 10 +++--- 4 files changed, 9 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 465b87b151b5..d662fdf44716 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -65,37 +65,6 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); - it("does not record AltGraph as a Ctrl+Alt shortcut", () => { - expect( - keybindingFromKeyboardEvent( - { - key: "<", - code: "Comma", - metaKey: false, - ctrlKey: true, - altKey: true, - shiftKey: false, - getModifierState: (modifier) => modifier === "AltGraph", - }, - "Win32", - ), - ).toBeNull(); - expect( - keybindingFromKeyboardEvent( - { - key: ",", - code: "Comma", - metaKey: false, - ctrlKey: true, - altKey: true, - shiftKey: false, - getModifierState: () => false, - }, - "Win32", - ), - ).toBe("mod+alt+,"); - }); - it("records macOS Option punctuation using portable key names", () => { expect( keybindingFromKeyboardEvent( @@ -106,7 +75,6 @@ describe("KeybindingsSettings.logic", () => { ctrlKey: false, altKey: true, shiftKey: false, - getModifierState: (modifier) => modifier === "AltGraph", }, "MacIntel", ), @@ -120,7 +88,6 @@ describe("KeybindingsSettings.logic", () => { ctrlKey: false, altKey: true, shiftKey: false, - getModifierState: (modifier) => modifier === "AltGraph", }, "MacIntel", ), diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 5694ce2cef80..84c12276a84b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -11,7 +11,6 @@ import { parseKeybindingWhenExpression, } from "@t3tools/shared/keybindings"; -import { isAltGraphShortcutEvent } from "../../keybindings"; import { isMacPlatform } from "../../lib/utils"; export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -323,11 +322,9 @@ export function normalizeShortcutKeyToken(key: string): string | null { } export function keybindingFromKeyboardEvent( - event: Pick & - Partial>, + event: Pick, platform: string, ): string | null { - if (isAltGraphShortcutEvent(event, platform)) return null; const portableOptionKey = isMacPlatform(platform) && event.altKey ? event.code === "Comma" diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 5305fcaf8c22..254ae6c0f01e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -597,7 +597,7 @@ describe("reasoning cycle shortcuts", () => { ); }); - it("ignores AltGraph punctuation without blocking intentional Ctrl+Alt shortcuts", () => { + it("matches only the reported layout key during AltGraph input", () => { const getAltGraphModifierState = (modifier: string) => modifier === "AltGraph"; assert.isNull( resolveShortcutCommand( @@ -632,7 +632,7 @@ describe("reasoning cycle shortcuts", () => { code: "Comma", ctrlKey: true, altKey: true, - getModifierState: () => false, + getModifierState: getAltGraphModifierState, }), DEFAULT_BINDINGS, { platform: "Win32", context: { terminalFocus: false } }, diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 0e7d83c927a0..6106e87893d7 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -73,7 +73,7 @@ function normalizeEventKey(key: string): string { return normalized; } -export function isAltGraphShortcutEvent( +function isAltGraphShortcutEvent( event: Pick, platform: string, ): boolean { @@ -81,9 +81,12 @@ export function isAltGraphShortcutEvent( return !isMacPlatform(platform) && event.getModifierState?.("AltGraph") === true; } -function resolveEventKeys(event: ShortcutEventLike): Set { +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; @@ -124,7 +127,7 @@ function matchesShortcut( platform = navigator.platform, ): boolean { 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 { @@ -221,7 +224,6 @@ export function resolveShortcutCommand( options?: ShortcutMatchOptions, ): KeybindingCommand | null { const platform = resolvePlatform(options); - if (isAltGraphShortcutEvent(event, platform)) return null; const context = resolveContext(options); for (let index = keybindings.length - 1; index >= 0; index -= 1) { From 344aaf695a6ac60992fd0c44564c8c099872cc2d Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sun, 6 Sep 2026 15:33:56 +0200 Subject: [PATCH 14/20] fix(web): remove unused thread jump hints export --- apps/web/src/keybindings.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 384b490427ef..604bc3cd56cb 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -310,14 +310,6 @@ export function reasoningCycleDirectionFromCommand( return null; } -export function shouldShowThreadJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, From e5398ac3e97b75034a33c093c1192713cee82e31 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Mon, 7 Sep 2026 20:29:25 +0200 Subject: [PATCH 15/20] fix(web): respect plan mode in reasoning shortcuts --- apps/web/src/components/chat/ChatComposer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 372b3d173f92..f45a6c3114b3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4181,6 +4181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderModels, selectedModel, selectedProvider, + settings.planModeEnabled, ), modelOptions: currentModelOptions, prompt: currentPrompt, @@ -4259,6 +4260,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderModels, setPrompt, setProviderModelOptions, + settings.planModeEnabled, stashCurrentPrompt, terminalOpen, ]); From 044b6d53b92396bc16f95867e1404b96f541f327 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 11 Sep 2026 17:26:40 +0200 Subject: [PATCH 16/20] fix(web): keep reasoning shortcuts in sync with hidden drafts Recompute the composer cursor and trigger from the restored draft when a pending question closes, so a menu opened from the custom answer does not stay open against the draft. Persist prompt-backed reasoning transitions and report blocked ones even while an approval or pending question hides the editor. --- apps/web/src/components/chat/ChatComposer.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4f481ef82597..c5877a76e65e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2419,12 +2419,24 @@ 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) return; + if (activePendingProgress !== null) { + restoreDraftAfterPendingRef.current = true; + return; + } promptRef.current = prompt; + if (restoreDraftAfterPendingRef.current) { + // The cursor and trigger still describe the custom-answer buffer, 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)); }, [activePendingProgress, prompt, promptRef]); @@ -4340,24 +4352,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) action: { type: "cycle", direction: reasoningDirection }, }); if (transition.status === "changed") { - if (transition.prompt !== currentPrompt && !editorShowsDraftPrompt) { - return; - } if (transition.prompt !== currentPrompt) { - const currentExpandedCursor = - composerEditorRef.current?.readSnapshot().expandedCursor ?? - expandCollapsedComposerCursor(currentPrompt, composerCursor); - const nextExpandedCursor = mapComposerCursorAcrossLeadingPromptChange( - currentPrompt, - transition.prompt, - currentExpandedCursor, - ); - promptRef.current = transition.prompt; setPrompt(transition.prompt); - setComposerCursor( - collapseExpandedComposerCursor(transition.prompt, nextExpandedCursor), - ); - setComposerTrigger(detectComposerTrigger(transition.prompt, nextExpandedCursor)); + // 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 when the draft returns. + if (editorShowsDraftPrompt) { + const currentExpandedCursor = + composerEditorRef.current?.readSnapshot().expandedCursor ?? + expandCollapsedComposerCursor(currentPrompt, composerCursor); + 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( @@ -4371,7 +4385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, ); } - } else if (transition.status === "blocked" && editorShowsDraftPrompt) { + } else if (transition.status === "blocked") { toastManager.add({ type: "info", title: "Remove “ultrathink” from the prompt text to change reasoning.", From ee9a5810c4b3c0d9cd7a869b6686b2a7fab90ebb Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sat, 12 Sep 2026 15:12:49 +0200 Subject: [PATCH 17/20] refactor(web): simplify reasoning shortcut handling Extract the reasoning cycle into a callback so the window shortcut listener no longer re-binds on every caret move, drop an unused effect dependency, collapse the model option branching in the shared transition, fold the two programmer-error statuses into unsupported, reword the unsupported toast, and document the shortcuts. --- apps/web/src/components/chat/ChatComposer.tsx | 158 ++++++++++-------- apps/web/src/components/chat/TraitsPicker.tsx | 25 ++- docs/user/composer.md | 4 + packages/shared/src/model.test.ts | 11 +- packages/shared/src/model.ts | 33 ++-- 5 files changed, 118 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c5877a76e65e..090a4a2665c1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2548,7 +2548,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, - prompt, promptRef, ]); @@ -4315,6 +4314,89 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false); }, [prompt]); + // The cursor is only needed as a fallback when the editor has no snapshot, + // so read it through a ref instead of re-binding the shortcut listener on + // every caret move. + const composerCursorRef = useRef(composerCursor); + composerCursorRef.current = composerCursor; + + 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 when the draft returns. + if (editorShowsDraftPrompt) { + const currentExpandedCursor = + composerEditorRef.current?.readSnapshot().expandedCursor ?? + expandCollapsedComposerCursor(currentPrompt, composerCursorRef.current); + 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, { @@ -4335,67 +4417,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (reasoningDirection !== null) { - 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: reasoningDirection }, - }); - if (transition.status === "changed") { - 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 when the draft returns. - if (editorShowsDraftPrompt) { - const currentExpandedCursor = - composerEditorRef.current?.readSnapshot().expandedCursor ?? - expandCollapsedComposerCursor(currentPrompt, composerCursor); - 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, - }, - ); - } - } else if (transition.status === "blocked") { - toastManager.add({ - type: "info", - title: "Remove “ultrathink” from the prompt text to change reasoning.", - }); - } else if (transition.status === "unsupported") { - toastManager.add({ - type: "info", - title: "This model does not advertise reasoning levels.", - }); - } + applyReasoningCycle(reasoningDirection); return; } if (pendingUserInputs.length > 0 && !isComposerApprovalState) { @@ -4411,22 +4433,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return () => window.removeEventListener("keydown", handler, true); }, [ activePendingProgress, - composerDraftTarget, - composerCursor, - composerModelOptions, - getComposerDraft, + applyReasoningCycle, isComposerApprovalState, isComposerModelPickerOpen, keybindings, pendingUserInputs.length, projectSelectionRequired, - selectedInstanceId, - selectedModel, - selectedProvider, - selectedProviderModels, - setPrompt, - setProviderModelOptions, - settings.planModeEnabled, stashCurrentPrompt, terminalOpen, ]); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 63c86dfd1c55..955bd417c8d7 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -12,6 +12,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, + isReasoningDescriptorId, normalizeModelSlug, resolveReasoningTransition, stripClaudeUltrathinkPrefix, @@ -140,25 +141,23 @@ export function resolveTraitsSelectChange(input: { 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 }, }); - if (transition.status === "changed") { - return { prompt: transition.prompt, modelOptions: transition.modelOptions }; - } - if (transition.status !== "not-applicable") { - return null; - } - - return { - prompt: input.prompt, - modelOptions: buildProviderOptionSelectionsFromDescriptors( - replaceDescriptorCurrentValue(input.descriptors, input.descriptor.id, input.value), - ), - }; + return transition.status === "changed" + ? { prompt: transition.prompt, modelOptions: transition.modelOptions } + : null; } function getSelectedTraits( diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..c0c4202e14f5 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -44,6 +44,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/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 77491820944e..96a0e85f7fd5 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -563,7 +563,7 @@ describe("resolveReasoningTransition", () => { capabilities: oneChoiceCaps, action: { type: "select", descriptorId: "reasoning", value: "missing" }, }), - ).toEqual({ status: "invalid", reason: "unknown-value" }); + ).toEqual({ status: "unsupported", reason: "unknown-value" }); }); it("replaces a stale persisted value when selecting the effective default", () => { @@ -590,15 +590,6 @@ describe("resolveReasoningTransition", () => { }); expect(selected).toEqual(cycled); }); - - it("returns not-applicable for non-reasoning mouse selections", () => { - expect( - transition({ - capabilities: claudeCaps, - action: { type: "select", descriptorId: "contextWindow", value: "200k" }, - }), - ).toEqual({ status: "not-applicable" }); - }); }); describe("stripClaudeUltrathinkPrefix", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 34ff59df26ca..1e4477db2161 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -245,12 +245,18 @@ export type ReasoningTransitionResult = | { status: "blocked"; reason: "ultrathink-in-prompt-body" } | { status: "unsupported"; - reason: "missing-reasoning-option" | "missing-choices" | "unsupported-prompt-injected-value"; - } - | { status: "not-applicable" } - | { status: "invalid"; reason: "unknown-value" }; + 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; @@ -339,10 +345,6 @@ export function resolveReasoningTransition(input: { action: ReasoningTransitionAction; }): ReasoningTransitionResult { const { capabilities, modelOptions, prompt, action } = input; - if (action.type === "select" && !REASONING_DESCRIPTOR_IDS.has(action.descriptorId)) { - return { status: "not-applicable" }; - } - const descriptor = findReasoningDescriptor(capabilities); if (!descriptor || (action.type === "select" && action.descriptorId !== descriptor.id)) { return { status: "unsupported", reason: "missing-reasoning-option" }; @@ -360,9 +362,6 @@ export function resolveReasoningTransition(input: { promptInjectedUltrathink && (hasLeadingPrefix || ultrathinkInBody); const persistedValue = resolvePersistedReasoningValue(descriptor, modelOptions); const rawPersistedValue = getProviderOptionStringSelectionValue(modelOptions, descriptor.id); - const persistedPromptInjectedValue = - rawPersistedValue !== undefined && - (descriptor.promptInjectedValues?.includes(rawPersistedValue) ?? false); const currentValue = promptControlsUltrathink ? ULTRATHINK_VALUE : persistedValue; const cycleOptions = isClaudeSlashCommandPrompt(prompt) ? descriptor.options.filter((option) => !descriptor.promptInjectedValues?.includes(option.id)) @@ -371,7 +370,7 @@ export function resolveReasoningTransition(input: { let targetValue: string; if (action.type === "select") { if (!descriptor.options.some((option) => option.id === action.value)) { - return { status: "invalid", reason: "unknown-value" }; + return { status: "unsupported", reason: "unknown-value" }; } targetValue = action.value; } else if (currentValue === undefined) { @@ -401,7 +400,7 @@ export function resolveReasoningTransition(input: { const targetOption = descriptor.options.find((option) => option.id === targetValue); if (!targetOption) { - return { status: "invalid", reason: "unknown-value" }; + return { status: "unsupported", reason: "unknown-value" }; } const targetIsPromptInjected = descriptor.promptInjectedValues?.includes(targetValue) ?? false; @@ -415,12 +414,12 @@ export function resolveReasoningTransition(input: { : 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) - : targetValue === persistedValue && !persistedPromptInjectedValue - ? rawPersistedValue === undefined || rawPersistedValue === persistedValue - ? (modelOptions ?? undefined) - : updateReasoningSelection(modelOptions, descriptor.id, targetValue) + : rawPersistedValue === undefined && targetValue === persistedValue + ? (modelOptions ?? undefined) : updateReasoningSelection(modelOptions, descriptor.id, targetValue); const normalizedInputOptions = modelOptions ?? undefined; From 9c5e85fb535b80aa2910289da62c52f169bf67fe Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sat, 12 Sep 2026 15:27:05 +0200 Subject: [PATCH 18/20] fix(web): rebuild composer state after hidden reasoning changes A reasoning shortcut during an approval updates the draft while the editor is hidden. Flag the draft for the same cursor and trigger rebuild that pending answers already use, and drop the render-time cursor ref. --- apps/web/src/components/chat/ChatComposer.tsx | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 090a4a2665c1..1085a4f55e5f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2430,8 +2430,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = prompt; if (restoreDraftAfterPendingRef.current) { - // The cursor and trigger still describe the custom-answer buffer, so - // rebuild both from the restored draft instead of clamping them. + // 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)); @@ -4314,12 +4315,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false); }, [prompt]); - // The cursor is only needed as a fallback when the editor has no snapshot, - // so read it through a ref instead of re-binding the shortcut listener on - // every caret move. - const composerCursorRef = useRef(composerCursor); - composerCursorRef.current = composerCursor; - const applyReasoningCycle = useCallback( (direction: "increase" | "decrease") => { const editorShowsDraftPrompt = !isComposerApprovalState && activePendingProgress === null; @@ -4357,11 +4352,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) 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 when the draft returns. - if (editorShowsDraftPrompt) { + // 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 ?? - expandCollapsedComposerCursor(currentPrompt, composerCursorRef.current); + composerEditorRef.current?.readSnapshot().expandedCursor ?? currentPrompt.length; const nextExpandedCursor = mapComposerCursorAcrossLeadingPromptChange( currentPrompt, transition.prompt, From b388902e17abc7342f4dd5c2aa4491e90ab765c0 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 15 Sep 2026 16:44:54 +0200 Subject: [PATCH 19/20] fix(web): resolve AltGraph merge conflict in keybindings Main added its own optional getModifierState declaration and an AltGraph guard that rejects punctuation keys. The branch already keeps only the layout key during AltGraph input, which covers the same case, so drop the duplicate declaration and the redundant guard. --- apps/web/src/keybindings.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index bc49d13c809f..1c4a580f4407 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -19,7 +19,6 @@ export interface ShortcutEventLike { ctrlKey: boolean; shiftKey: boolean; altKey: boolean; - getModifierState?(keyArg: string): boolean; } export interface ShortcutModifierStateLike { @@ -136,12 +135,6 @@ 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, platform).has(shortcut.key); } From b6119efabe1f709fd125953e28c2b9e1aed4b47b Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sat, 26 Sep 2026 16:40:03 +0200 Subject: [PATCH 20/20] fix(web): pass platform to resolveEventKeys in rich-text bold shortcut --- apps/web/src/keybindings.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index abd610391d7f..227f1781ea9b 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -433,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