diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 09dadf66..1efa49b9 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -15,6 +15,7 @@ const PreferencesSchema = z.object({ lastUsedBinary: z.string().optional(), environmentVariables: z.record(z.string()).default({}), modelRecents: z.array(ModelPreferenceSchema).default([]), + modelThinkingSelections: z.record(z.string(), z.string()).default({}), diffViewMode: z.enum(["split", "unified"]).default("split"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index a2a83f7e..382d2a9b 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -53,6 +53,7 @@ import InfoView from "../info-view" import InstanceServiceStatus from "../instance-service-status" import AgentSelector from "../agent-selector" import ModelSelector from "../model-selector" +import ThinkingSelector from "../thinking-selector" import CommandPalette from "../command-palette" import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" @@ -432,6 +433,14 @@ const InstanceShell2: Component = (props) => { return true } + const focusVariantSelectorControl = () => { + const input = leftDrawerContentEl()?.querySelector("[data-thinking-selector]") + if (!input) return false + input.focus() + setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) + return true + } + createEffect(() => { const pending = pendingSidebarAction() if (!pending) return @@ -444,7 +453,12 @@ const InstanceShell2: Component = (props) => { setPendingSidebarAction(null) return } - const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl() + const handled = + action === "focus-agent-selector" + ? focusAgentSelectorControl() + : action === "focus-model-selector" + ? focusModelSelectorControl() + : focusVariantSelectorControl() if (handled) { setPendingSidebarAction(null) } @@ -905,9 +919,12 @@ const InstanceShell2: Component = (props) => { - + + + + = (props) => { currentModel={activeSession().model} onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)} /> + + )} diff --git a/packages/ui/src/components/thinking-selector.tsx b/packages/ui/src/components/thinking-selector.tsx new file mode 100644 index 00000000..14f69443 --- /dev/null +++ b/packages/ui/src/components/thinking-selector.tsx @@ -0,0 +1,103 @@ +import { Combobox } from "@kobalte/core/combobox" +import { createEffect, createMemo } from "solid-js" +import { providers, fetchProviders } from "../stores/sessions" +import { ChevronDown } from "lucide-solid" +import { getLogger } from "../lib/logger" +import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences" + +const log = getLogger("session") + +interface ThinkingSelectorProps { + instanceId: string + currentModel: { providerId: string; modelId: string } +} + +type ThinkingOption = { + key: string + label: string + value: string | undefined +} + +export default function ThinkingSelector(props: ThinkingSelectorProps) { + const instanceProviders = () => providers().get(props.instanceId) || [] + + createEffect(() => { + if (instanceProviders().length === 0) { + fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error)) + } + }) + + const variantKeys = createMemo(() => { + const { providerId, modelId } = props.currentModel + const provider = instanceProviders().find((p) => p.id === providerId) + const model = provider?.models.find((m) => m.id === modelId) + return model?.variantKeys ?? [] + }) + + const options = createMemo(() => { + const keys = variantKeys() + return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))] + }) + + const currentValue = createMemo(() => { + const selected = getModelThinkingSelection(props.currentModel) + const keys = variantKeys() + if (selected && keys.includes(selected)) { + return options().find((opt) => opt.value === selected) + } + return options()[0] + }) + + const handleChange = (value: ThinkingOption | null) => { + if (!value) return + setModelThinkingSelection(props.currentModel, value.value) + } + + const triggerPrimary = createMemo(() => { + const selected = currentValue()?.value + return selected ? `Thinking: ${selected}` : "Thinking: Default" + }) + + return ( + + ) +} diff --git a/packages/ui/src/lib/hooks/use-app-lifecycle.ts b/packages/ui/src/lib/hooks/use-app-lifecycle.ts index e113bfa0..0721b6dc 100644 --- a/packages/ui/src/lib/hooks/use-app-lifecycle.ts +++ b/packages/ui/src/lib/hooks/use-app-lifecycle.ts @@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) { if (!instance) return emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" }) }, + () => { + const instance = options.getActiveInstance() + if (!instance) return + emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" }) + }, ) registerEscapeShortcut( diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index ee450bad..e99acbe2 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -374,6 +374,20 @@ export function useCommands(options: UseCommandsOptions) { }, }) + commandRegistry.register({ + id: "open-variant-selector", + label: "Select Model Variant", + description: "Choose a thinking effort for the current model", + category: "Agent & Model", + keywords: ["variant", "thinking", "reasoning", "effort"], + shortcut: { key: "T", meta: true, shift: true }, + action: () => { + const instance = activeInstance() + if (!instance) return + emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" }) + }, + }) + commandRegistry.register({ id: "open-agent-selector", label: "Open Agent Selector", diff --git a/packages/ui/src/lib/session-sidebar-events.ts b/packages/ui/src/lib/session-sidebar-events.ts index a8529a24..de55cae3 100644 --- a/packages/ui/src/lib/session-sidebar-events.ts +++ b/packages/ui/src/lib/session-sidebar-events.ts @@ -1,4 +1,8 @@ -export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list" +export type SessionSidebarRequestAction = + | "focus-agent-selector" + | "focus-model-selector" + | "focus-variant-selector" + | "show-session-list" export interface SessionSidebarRequestDetail { instanceId: string diff --git a/packages/ui/src/lib/shortcuts/agent.ts b/packages/ui/src/lib/shortcuts/agent.ts index 26b8297b..f99a0983 100644 --- a/packages/ui/src/lib/shortcuts/agent.ts +++ b/packages/ui/src/lib/shortcuts/agent.ts @@ -1,6 +1,10 @@ import { keyboardRegistry } from "../keyboard-registry" -export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) { +export function registerAgentShortcuts( + focusModelSelector: () => void, + openAgentSelector: () => void, + focusVariantSelector: () => void, +) { const isMac = () => navigator.platform.toLowerCase().includes("mac") keyboardRegistry.register({ @@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent description: "open agent", context: "global", }) + + keyboardRegistry.register({ + id: "focus-variant", + key: "T", + modifiers: { ctrl: !isMac(), meta: isMac(), shift: true }, + handler: focusVariantSelector, + description: "focus thinking", + context: "global", + }) } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 1ae244b4..9b48b53b 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -39,6 +39,7 @@ export interface Preferences { lastUsedBinary?: string environmentVariables: Record modelRecents: ModelPreference[] + modelThinkingSelections: Record diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference @@ -71,6 +72,7 @@ const defaultPreferences: Preferences = { showTimelineTools: true, environmentVariables: {}, modelRecents: [], + modelThinkingSelections: {}, diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", @@ -102,6 +104,11 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents const modelRecents = sourceModelRecents.map((item) => ({ ...item })) + const modelThinkingSelections = { + ...defaultPreferences.modelThinkingSelections, + ...(sanitized.modelThinkingSelections ?? {}), + } + return { showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, @@ -109,6 +116,7 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, environmentVariables, modelRecents, + modelThinkingSelections, diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, @@ -118,6 +126,35 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio } } +function getModelKey(model: { providerId: string; modelId: string }): string { + return `${model.providerId}/${model.modelId}` +} + +function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined { + if (!model.providerId || !model.modelId) return undefined + return preferences().modelThinkingSelections?.[getModelKey(model)] +} + +function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void { + if (!model.providerId || !model.modelId) return + const key = getModelKey(model) + const current = preferences().modelThinkingSelections?.[key] + if (current === value) return + + updateConfig((draft) => { + const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) } + if (!value) { + delete selections[key] + } else { + selections[key] = value + } + draft.preferences = normalizePreferences({ + ...draft.preferences, + modelThinkingSelections: selections, + }) + }) +} + const [internalConfig, setInternalConfig] = createSignal(buildFallbackConfig()) const config = createMemo>(() => internalConfig()) @@ -527,6 +564,8 @@ export { addEnvironmentVariable, removeEnvironmentVariable, addRecentModelPreference, + getModelThinkingSelection, + setModelThinkingSelection, setAgentModelPreference, getAgentModelPreference, setDiffViewMode, @@ -540,4 +579,3 @@ export { } - diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 5f06a16b..29f21129 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,8 +1,8 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { instances } from "./instances" -import { addRecentModelPreference, setAgentModelPreference } from "./preferences" -import { sessions, withSession } from "./session-state" +import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences" +import { providers, sessions, withSession } from "./session-state" import { getDefaultModel, isModelValid } from "./session-models" import { updateSessionInfo } from "./message-v2/session-info" import { messageStoreBus } from "./message-v2/bus" @@ -11,6 +11,22 @@ import { requestData } from "../lib/opencode-api" const log = getLogger("actions") +function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] { + if (!model.providerId || !model.modelId) return [] + const instanceProviders = providers().get(instanceId) || [] + const provider = instanceProviders.find((p) => p.id === model.providerId) + const match = provider?.models.find((m) => m.id === model.modelId) + return match?.variantKeys ?? [] +} + +function getThinkingVariantToSend(instanceId: string, model: { providerId: string; modelId: string }): string | undefined { + const selected = getModelThinkingSelection(model) + if (!selected) return undefined + const keys = getVariantKeysForModel(instanceId, model) + if (keys.length === 0) return undefined + return keys.includes(selected) ? selected : undefined +} + const ID_LENGTH = 26 const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" @@ -170,6 +186,12 @@ async function sendMessage( modelID: session.model.modelId, }, }), + ...(session.model.providerId && + session.model.modelId && + (() => { + const variant = getThinkingVariantToSend(instanceId, session.model) + return variant ? { variant } : {} + })()), } log.info("sendMessage", { @@ -215,6 +237,7 @@ async function executeCustomCommand( messageID: string agent?: string model?: string + variant?: string } = { command: commandName, arguments: args, @@ -227,6 +250,8 @@ async function executeCustomCommand( if (session.model.providerId && session.model.modelId) { body.model = `${session.model.providerId}/${session.model.modelId}` + const variant = getThinkingVariantToSend(instanceId, session.model) + if (variant) body.variant = variant } await requestData( diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index a2c53276..063b6e49 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -483,6 +483,7 @@ async function fetchProviders(instanceId: string): Promise { providerId: provider.id, limit: model.limit, cost: model.cost, + variantKeys: Object.keys(model.variants ?? {}), })), })) diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 3273b0ed..c6c1b808 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -121,21 +121,26 @@ session-sidebar-controls .selector-trigger-primary { } .sidebar-selector-hints { - @apply flex items-center gap-2 w-full; - justify-content: space-between; + @apply grid gap-2 w-full; + grid-template-columns: repeat(3, minmax(0, 1fr)); } .sidebar-selector-hint--left, +.sidebar-selector-hint--center, .sidebar-selector-hint--right { - @apply flex-1; + justify-content: center; } -.sidebar-selector-hint--left { - justify-content: flex-start; +@media (max-width: 520px) { + .sidebar-selector-hints { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } -.sidebar-selector-hint--right { - justify-content: flex-end; +@media (max-width: 360px) { + .sidebar-selector-hints { + grid-template-columns: 1fr; + } } .session-header-hints { diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 6ed17571..e26f0a06 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -85,6 +85,7 @@ export interface Model { id: string name: string providerId: string + variantKeys?: string[] limit?: { context?: number output?: number