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(),
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"),

View File

@@ -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<InstanceShellProps> = (props) => {
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(() => {
const pending = pendingSidebarAction()
if (!pending) return
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (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<InstanceShellProps> = (props) => {
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</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" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+t" />
</span>
</div>
<ModelSelector
@@ -916,6 +933,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
</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
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
() => {
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
},
)
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({
id: "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 {
instanceId: string

View File

@@ -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",
})
}

View File

@@ -39,6 +39,7 @@ export interface Preferences {
lastUsedBinary?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
modelThinkingSelections: Record<string, string>
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<Preferences> & { 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<Preferences> & { 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<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 config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
@@ -527,6 +564,8 @@ export {
addEnvironmentVariable,
removeEnvironmentVariable,
addRecentModelPreference,
getModelThinkingSelection,
setModelThinkingSelection,
setAgentModelPreference,
getAgentModelPreference,
setDiffViewMode,
@@ -540,4 +579,3 @@ export {
}

View File

@@ -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(

View File

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

View File

@@ -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 {

View File

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