feat(ui): add model thinking selector

This commit is contained in:
Shantur Rathore
2026-01-25 17:39:38 +00:00
parent b83c69f002
commit 4aae8ab720
12 changed files with 243 additions and 14 deletions

View File

@@ -15,6 +15,7 @@ const PreferencesSchema = z.object({
lastUsedBinary: z.string().optional(), lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}), environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]), modelRecents: z.array(ModelPreferenceSchema).default([]),
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"), diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),

View File

@@ -53,6 +53,7 @@ import InfoView from "../info-view"
import InstanceServiceStatus from "../instance-service-status" import InstanceServiceStatus from "../instance-service-status"
import AgentSelector from "../agent-selector" import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector" import ModelSelector from "../model-selector"
import ThinkingSelector from "../thinking-selector"
import CommandPalette from "../command-palette" import CommandPalette from "../command-palette"
import PermissionNotificationBanner from "../permission-notification-banner" import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal" import PermissionApprovalModal from "../permission-approval-modal"
@@ -432,6 +433,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return true return true
} }
const focusVariantSelectorControl = () => {
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
createEffect(() => { createEffect(() => {
const pending = pendingSidebarAction() const pending = pendingSidebarAction()
if (!pending) return if (!pending) return
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setPendingSidebarAction(null) setPendingSidebarAction(null)
return return
} }
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl() const handled =
action === "focus-agent-selector"
? focusAgentSelectorControl()
: action === "focus-model-selector"
? focusModelSelectorControl()
: focusVariantSelectorControl()
if (handled) { if (handled) {
setPendingSidebarAction(null) setPendingSidebarAction(null)
} }
@@ -905,9 +919,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<span class="hint sidebar-selector-hint sidebar-selector-hint--left"> <span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" /> <Kbd shortcut="cmd+shift+a" />
</span> </span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right"> <span class="hint sidebar-selector-hint sidebar-selector-hint--center">
<Kbd shortcut="cmd+shift+m" /> <Kbd shortcut="cmd+shift+m" />
</span> </span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+t" />
</span>
</div> </div>
<ModelSelector <ModelSelector
@@ -916,6 +933,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
currentModel={activeSession().model} currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)} onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/> />
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
</div> </div>
</> </>
)} )}

View File

@@ -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<ThinkingOption[]>(() => {
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 (
<div class="sidebar-selector">
<Combobox<ThinkingOption>
value={currentValue()}
onChange={handleChange}
options={options()}
optionValue="key"
optionLabel="label"
placeholder="Thinking: Default"
itemComponent={(itemProps) => (
<Combobox.Item item={itemProps.item} class="selector-option">
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
>
<Combobox.Control class="relative w-full" data-thinking-selector-control>
<Combobox.Input class="sr-only" data-thinking-selector />
<Combobox.Trigger class="selector-trigger">
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
</div>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>
</Combobox.Trigger>
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class="selector-popover">
<Combobox.Listbox class="selector-listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
</div>
)
}

View File

@@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
if (!instance) return if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" }) emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
}, },
() => {
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
},
) )
registerEscapeShortcut( registerEscapeShortcut(

View File

@@ -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({ commandRegistry.register({
id: "open-agent-selector", id: "open-agent-selector",
label: "Open Agent Selector", label: "Open Agent Selector",

View File

@@ -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 { export interface SessionSidebarRequestDetail {
instanceId: string instanceId: string

View File

@@ -1,6 +1,10 @@
import { keyboardRegistry } from "../keyboard-registry" 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") const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({ keyboardRegistry.register({
@@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent
description: "open agent", description: "open agent",
context: "global", context: "global",
}) })
keyboardRegistry.register({
id: "focus-variant",
key: "T",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: focusVariantSelector,
description: "focus thinking",
context: "global",
})
} }

View File

@@ -39,6 +39,7 @@ export interface Preferences {
lastUsedBinary?: string lastUsedBinary?: string
environmentVariables: Record<string, string> environmentVariables: Record<string, string>
modelRecents: ModelPreference[] modelRecents: ModelPreference[]
modelThinkingSelections: Record<string, string>
diffViewMode: DiffViewMode diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference
@@ -71,6 +72,7 @@ const defaultPreferences: Preferences = {
showTimelineTools: true, showTimelineTools: true,
environmentVariables: {}, environmentVariables: {},
modelRecents: [], modelRecents: [],
modelThinkingSelections: {},
diffViewMode: "split", diffViewMode: "split",
toolOutputExpansion: "expanded", toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded", diagnosticsExpansion: "expanded",
@@ -102,6 +104,11 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
const modelRecents = sourceModelRecents.map((item) => ({ ...item })) const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
const modelThinkingSelections = {
...defaultPreferences.modelThinkingSelections,
...(sanitized.modelThinkingSelections ?? {}),
}
return { return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
@@ -109,6 +116,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
environmentVariables, environmentVariables,
modelRecents, modelRecents,
modelThinkingSelections,
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
@@ -118,6 +126,35 @@ function normalizePreferences(pref?: Partial<Preferences> & { 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<ConfigData>(buildFallbackConfig()) const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig()) const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
@@ -527,6 +564,8 @@ export {
addEnvironmentVariable, addEnvironmentVariable,
removeEnvironmentVariable, removeEnvironmentVariable,
addRecentModelPreference, addRecentModelPreference,
getModelThinkingSelection,
setModelThinkingSelection,
setAgentModelPreference, setAgentModelPreference,
getAgentModelPreference, getAgentModelPreference,
setDiffViewMode, setDiffViewMode,
@@ -540,4 +579,3 @@ export {
} }

View File

@@ -1,8 +1,8 @@
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances" import { instances } from "./instances"
import { addRecentModelPreference, setAgentModelPreference } from "./preferences" import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
import { sessions, withSession } from "./session-state" import { providers, sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models" import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
@@ -11,6 +11,22 @@ import { requestData } from "../lib/opencode-api"
const log = getLogger("actions") 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 ID_LENGTH = 26
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -170,6 +186,12 @@ async function sendMessage(
modelID: session.model.modelId, modelID: session.model.modelId,
}, },
}), }),
...(session.model.providerId &&
session.model.modelId &&
(() => {
const variant = getThinkingVariantToSend(instanceId, session.model)
return variant ? { variant } : {}
})()),
} }
log.info("sendMessage", { log.info("sendMessage", {
@@ -215,6 +237,7 @@ async function executeCustomCommand(
messageID: string messageID: string
agent?: string agent?: string
model?: string model?: string
variant?: string
} = { } = {
command: commandName, command: commandName,
arguments: args, arguments: args,
@@ -227,6 +250,8 @@ async function executeCustomCommand(
if (session.model.providerId && session.model.modelId) { if (session.model.providerId && session.model.modelId) {
body.model = `${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( await requestData(

View File

@@ -483,6 +483,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
providerId: provider.id, providerId: provider.id,
limit: model.limit, limit: model.limit,
cost: model.cost, cost: model.cost,
variantKeys: Object.keys(model.variants ?? {}),
})), })),
})) }))

View File

@@ -121,21 +121,26 @@ session-sidebar-controls .selector-trigger-primary {
} }
.sidebar-selector-hints { .sidebar-selector-hints {
@apply flex items-center gap-2 w-full; @apply grid gap-2 w-full;
justify-content: space-between; grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.sidebar-selector-hint--left, .sidebar-selector-hint--left,
.sidebar-selector-hint--center,
.sidebar-selector-hint--right { .sidebar-selector-hint--right {
@apply flex-1; justify-content: center;
} }
.sidebar-selector-hint--left { @media (max-width: 520px) {
justify-content: flex-start; .sidebar-selector-hints {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
.sidebar-selector-hint--right { @media (max-width: 360px) {
justify-content: flex-end; .sidebar-selector-hints {
grid-template-columns: 1fr;
}
} }
.session-header-hints { .session-header-hints {

View File

@@ -85,6 +85,7 @@ export interface Model {
id: string id: string
name: string name: string
providerId: string providerId: string
variantKeys?: string[]
limit?: { limit?: {
context?: number context?: number
output?: number output?: number