diff --git a/CHANGELOG.md b/CHANGELOG.md index 179d854..0950858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ version with its date and start a fresh empty `[Unreleased]` above it. thinking effort levels. Choices persist per model and are applied to every request. +### Removed + +- The global "Effort" dropdown in the input toolbar: reasoning effort + is now configured only through the per-model editor in the model + selector, which already offered the same levels. Models without an + explicit choice fall back to their server default effort. + ### Fixed - The model selector dropdown opens above the model button again on diff --git a/README.md b/README.md index 93daad9..e624f2f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Open the chat sidebar from the ribbon icon or the command palette (`Open Qoderia **Bash Mode (`!`)** — Press `!` in an empty input to run a shell command in the vault directory directly. -**Model & Effort Controls** — Pick a model and reasoning effort below the input, and watch context usage reported by the Qoder Agent SDK. +**Model & Effort Controls** — Pick a model below the input and tune its reasoning effort in the per-model editor, and watch context usage reported by the Qoder Agent SDK. **MCP Servers** — Connect external tools over the Model Context Protocol (stdio, SSE, HTTP), configured in-app. @@ -38,7 +38,7 @@ Open the chat sidebar from the ribbon icon or the command palette (`Open Qoderia | `efficient` | Fast and cost-effective | | `lite` | Lightweight and fast | -Adaptive thinking models accept an effort level of `Low`, `Med`, `High`, `XHigh`, or `Max`. The selector consumes the runtime catalog returned by the Qoder Agent SDK, including models configured in qodercli. +Adaptive thinking models accept an effort level of `Low`, `Med`, `High`, `XHigh`, or `Max`, configured per model through the editor in the model selector. The selector consumes the runtime catalog returned by the Qoder Agent SDK, including models configured in qodercli. ## Requirements diff --git a/src/app/settings/settings-storage.ts b/src/app/settings/settings-storage.ts index 668e1ff..bd44117 100644 --- a/src/app/settings/settings-storage.ts +++ b/src/app/settings/settings-storage.ts @@ -23,7 +23,6 @@ export const DEFAULT_QODERIAN_SETTINGS: QoderianSettings = { permissionMode: 'acceptEdits', model: 'auto', - effortLevel: 'high', enableAutoTitleGeneration: true, titleGenerationModel: 'auto', diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 76e3cf1..ca13162 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -159,7 +159,6 @@ export interface QoderianSettings { // Qoder model and reasoning effort model: string; - effortLevel: string; enableAutoTitleGeneration: boolean; titleGenerationModel: string; diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index a84a4ef..3e9477b 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -39,8 +39,6 @@ export async function destroyTab(tab: TabData): Promise { tab.ui.fileContextManager?.destroy(); tab.ui.modelSelector?.destroy(); tab.ui.modelSelector = null; - tab.ui.effortSelector?.destroy(); - tab.ui.effortSelector = null; tab.ui.slashCommandDropdown?.destroy(); tab.ui.slashCommandDropdown = null; tab.ui.instructionModeManager?.destroy(); diff --git a/src/features/chat/tabs/tab-qoder-context.ts b/src/features/chat/tabs/tab-qoder-context.ts index 482e716..fd47d99 100644 --- a/src/features/chat/tabs/tab-qoder-context.ts +++ b/src/features/chat/tabs/tab-qoder-context.ts @@ -11,7 +11,6 @@ import type { TabData, TabManagerViewHost, TabQoderContext } from './types'; export type TabQoderSettings = Record & { model: string; - effortLevel: string; permissionMode: QoderianSettings['permissionMode']; }; @@ -95,7 +94,6 @@ export function refreshTabQoderUI(tab: TabData, plugin: QoderianPlugin): void { const permissionMode = getTabPermissionMode(tab, plugin); tab.ui.modelSelector?.updateDisplay(); tab.ui.modelSelector?.renderOptions(); - tab.ui.effortSelector?.updateDisplay(); tab.ui.permissionToggle?.updateDisplay(); tab.dom.inputWrapper.toggleClass('qoderian-input-plan-mode', permissionMode === 'plan'); } diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index bd5808f..0dc2f1e 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -149,7 +149,6 @@ export function createTab(options: TabCreateOptions): TabData { fileContextManager: null, imageContextManager: null, modelSelector: null, - effortSelector: null, externalContextSelector: null, mcpServerSelector: null, permissionToggle: null, @@ -415,7 +414,6 @@ function initializeInputToolbar( settings.model = model; modelConfig.applyModelDefaults(model, settings); }); - tab.ui.effortSelector?.updateDisplay(); tab.ui.modelSelector?.updateDisplay(); tab.ui.modelSelector?.renderOptions(); applyQoderUIGating(tab, plugin); @@ -427,7 +425,6 @@ function initializeInputToolbar( settings.model = model; modelConfig.applyModelDefaults(model, settings); }); - tab.ui.effortSelector?.updateDisplay(); tab.ui.modelSelector?.updateDisplay(); tab.ui.modelSelector?.renderOptions(); @@ -438,11 +435,6 @@ function initializeInputToolbar( tab.state.usage = recalculateUsageForModel(currentUsage, model, newContextWindow); } }, - onEffortLevelChange: async (effort: string) => { - await updateTabQoderSettings(tab, plugin, (settings) => { - settings.effortLevel = effort; - }); - }, onModelOverrideChange: async (model: string, override: Partial) => { await updateTabQoderSettings(tab, plugin, (settings) => { const current = getQoderSettings(settings).modelOverrides; @@ -487,7 +479,6 @@ function initializeInputToolbar( }); tab.ui.modelSelector = toolbarComponents.modelSelector; - tab.ui.effortSelector = toolbarComponents.effortSelector; tab.ui.contextUsageMeter = toolbarComponents.contextUsageMeter; tab.ui.externalContextSelector = toolbarComponents.externalContextSelector; tab.ui.mcpServerSelector = toolbarComponents.mcpServerSelector; diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index 59d1d97..360dfd2 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -20,7 +20,6 @@ import type { FileContextManager } from '../ui/file-context/file-context-manager import type { ImageContextManager } from '../ui/image-context'; import type { ContextUsageMeter, - EffortSelector, ExternalContextSelector, McpServerSelector, ModelSelector, @@ -118,7 +117,6 @@ export interface TabUIComponents { fileContextManager: FileContextManager | null; imageContextManager: ImageContextManager | null; modelSelector: ModelSelector | null; - effortSelector: EffortSelector | null; externalContextSelector: ExternalContextSelector | null; mcpServerSelector: McpServerSelector | null; permissionToggle: PermissionToggle | null; diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index 99ea996..0ec25d8 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -11,14 +11,12 @@ import type { import type { McpServerManager } from '../../../qoder/mcp/mcp-server-manager'; import { appendCheckIcon, appendMcpIcon } from '../../../shared/icons'; import { - EffortSelector, ModelSelector, PermissionToggle, type ToolbarCallbacks, } from './toolbar/toolbar-selectors'; export { - EffortSelector, ModelSelector, PermissionToggle, type ToolbarCallbacks, @@ -733,14 +731,12 @@ export function createInputToolbar( callbacks: ToolbarCallbacks ): { modelSelector: ModelSelector; - effortSelector: EffortSelector; contextUsageMeter: ContextUsageMeter | null; externalContextSelector: ExternalContextSelector; mcpServerSelector: McpServerSelector; permissionToggle: PermissionToggle; } { const modelSelector = new ModelSelector(parentEl, callbacks); - const effortSelector = new EffortSelector(parentEl, callbacks); const contextUsageMeter = new ContextUsageMeter(parentEl); const externalContextSelector = new ExternalContextSelector(parentEl, callbacks); const mcpServerSelector = new McpServerSelector(parentEl); @@ -748,7 +744,6 @@ export function createInputToolbar( return { modelSelector, - effortSelector, contextUsageMeter, externalContextSelector, mcpServerSelector, diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index 893769c..5d6a04f 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -28,14 +28,12 @@ function runToolbarAction(action: () => Promise, failureMessage: string): export interface ToolbarSettings { model: string; - effortLevel: string; permissionMode: PermissionMode; [key: string]: unknown; } export interface ToolbarCallbacks { onModelChange: (model: string) => Promise; - onEffortLevelChange: (effort: string) => Promise; onPermissionModeChange: (mode: PermissionMode) => Promise; /** Per-model editor overrides (context window tier, thinking toggle). */ onModelOverrideChange?: (model: string, override: Partial) => Promise; @@ -438,14 +436,8 @@ export class ModelSelector { }); const defaultEffort = efforts.find(effort => effort.isDefault)?.value ?? efforts[0]?.value; - const globalEffort = typeof settings.effortLevel === 'string' - ? settings.effortLevel - : undefined; - // Without an override the global effort wins when the model offers it, - // otherwise the server default applies. - const fallbackEffort = globalEffort && efforts.some(effort => effort.value === globalEffort) - ? globalEffort - : defaultEffort; + // Without an override the server default applies. + const fallbackEffort = defaultEffort; const effectiveEffort = override?.thinkingEffort ?? fallbackEffort; for (const effort of efforts) { const selected = effort.value === effectiveEffort; @@ -486,90 +478,6 @@ export class ModelSelector { } } -export class EffortSelector { - private readonly container: HTMLElement; - private effortEl: HTMLElement | null = null; - private effortGearsEl: HTMLElement | null = null; - private popover: ClickPopover | null = null; - - constructor(parentEl: HTMLElement, private readonly callbacks: ToolbarCallbacks) { - this.container = parentEl.createDiv({ cls: 'qoderian-thinking-selector' }); - this.render(); - } - - destroy(): void { - this.popover?.destroy(); - this.popover = null; - } - - private render(): void { - this.container.empty(); - this.effortEl = this.container.createDiv({ cls: 'qoderian-thinking-effort' }); - this.effortEl.createSpan({ cls: 'qoderian-thinking-label-text', text: 'Effort:' }); - this.effortGearsEl = this.effortEl.createDiv({ cls: 'qoderian-thinking-gears' }); - this.updateDisplay(); - } - - private renderEffortGears(): void { - if (!this.effortGearsEl) return; - this.popover?.destroy(); - this.effortGearsEl.empty(); - - const currentEffort = this.callbacks.getSettings().effortLevel; - const modelConfig = this.callbacks.getModelConfig(); - const model = this.callbacks.getSettings().model; - const options = modelConfig.getReasoningOptions(model); - const currentInfo = options.find(effort => effort.value === currentEffort); - - const currentEl = this.effortGearsEl.createDiv({ - cls: 'qoderian-thinking-current', - text: currentInfo?.label || options[0]?.label || 'High', - }); - const optionsEl = this.effortGearsEl.createDiv({ cls: 'qoderian-thinking-options' }); - - for (const effort of [...options].reverse()) { - const option = optionsEl.createDiv({ cls: 'qoderian-thinking-gear', text: effort.label }); - option.setAttribute('role', 'option'); - option.setAttribute('aria-selected', String(effort.value === currentEffort)); - if (effort.value === currentEffort) option.addClass('selected'); - - option.addEventListener('click', (event) => { - event.stopPropagation(); - this.popover?.close(); - runToolbarAction(async () => { - await this.callbacks.onEffortLevelChange(effort.value); - this.updateDisplay(); - }, 'Failed to change effort level'); - }); - } - - this.popover = new ClickPopover( - this.effortGearsEl, - currentEl, - optionsEl, - 'qoderian-thinking-gears--open', - ); - } - - updateDisplay(): void { - const settings = this.callbacks.getSettings(); - const modelConfig = this.callbacks.getModelConfig(); - const options = modelConfig.getReasoningOptions(settings.model); - const defaultValue = modelConfig.getDefaultReasoningValue(settings.model); - const shouldHide = options.length === 0 - || (options.length === 1 && options[0]?.value === defaultValue); - - if (shouldHide) { - this.popover?.close(); - this.effortEl?.addClass('qoderian-hidden'); - return; - } - - this.effortEl?.removeClass('qoderian-hidden'); - this.renderEffortGears(); - } -} - export class PermissionToggle { private readonly container: HTMLElement; private buttonEl: HTMLElement | null = null; diff --git a/src/qoder/config/qoder-settings-reconciler.ts b/src/qoder/config/qoder-settings-reconciler.ts index 8b0c160..a6ae203 100644 --- a/src/qoder/config/qoder-settings-reconciler.ts +++ b/src/qoder/config/qoder-settings-reconciler.ts @@ -67,14 +67,10 @@ export function normalizeQoderSettings(settings: Record): boole } if (model) { - const allowedEffortLevels = new Set( - qoderModelConfig.getReasoningOptions(model).map((option) => option.value), - ); - if ( - typeof settings.effortLevel !== 'string' - || !allowedEffortLevels.has(settings.effortLevel) - ) { - settings.effortLevel = qoderModelConfig.getDefaultReasoningValue(model); + // The global effort selector was removed; per-model editor overrides now + // own the reasoning effort. Drop the legacy field so it does not linger. + if ('effortLevel' in settings) { + delete settings.effortLevel; changed = true; } } diff --git a/src/qoder/models/model-catalog.ts b/src/qoder/models/model-catalog.ts index 2a6a0ad..8bae0a5 100644 --- a/src/qoder/models/model-catalog.ts +++ b/src/qoder/models/model-catalog.ts @@ -93,13 +93,6 @@ export function normalizeEffortLevel( return DEFAULT_EFFORT_LEVEL[normalizeModelId(model)] ?? 'high'; } -export function resolveEffortLevel( - model: string, - effortLevel: unknown, -): EffortLevel { - return normalizeEffortLevel(model, effortLevel); -} - export const CONTEXT_WINDOW_STANDARD = 200_000; export const CONTEXT_WINDOW_1M = 1_000_000; diff --git a/src/qoder/models/qoder-model-config.ts b/src/qoder/models/qoder-model-config.ts index 1c50f85..6c4e86a 100644 --- a/src/qoder/models/qoder-model-config.ts +++ b/src/qoder/models/qoder-model-config.ts @@ -7,6 +7,7 @@ import { getQoderSettings, updateQoderSettings } from '../config/settings'; import { DEFAULT_EFFORT_LEVEL, EFFORT_LEVELS, + type EffortLevel, getContextWindowSize, normalizeEffortLevel, sortThinkingEfforts, @@ -102,7 +103,6 @@ export const qoderModelConfig: QoderModelConfig = { const target = settings as Record; const runtimeModel = toQoderRuntimeModelId(model); - target.effortLevel = normalizeEffortLevel(runtimeModel, target.effortLevel); updateQoderSettings(target, { lastModel: runtimeModel }); }, @@ -117,5 +117,18 @@ export const qoderModelConfig: QoderModelConfig = { }; +/** + * Reasoning effort to send to qodercli for a model: the per-model editor + * override when set, otherwise the model's built-in default tier. + */ +export function resolveModelReasoningEffort( + model: string, + settings: Record, +): EffortLevel { + const runtimeModel = toQoderRuntimeModelId(model); + const override = getQoderSettings(settings).modelOverrides[runtimeModel]; + return normalizeEffortLevel(runtimeModel, override?.thinkingEffort); +} + /** Re-export for type-only use elsewhere in the settings UI. */ export type { UIOption }; diff --git a/src/qoder/runtime/qoder-cold-start-query.ts b/src/qoder/runtime/qoder-cold-start-query.ts index 4bd9f5e..e471481 100644 --- a/src/qoder/runtime/qoder-cold-start-query.ts +++ b/src/qoder/runtime/qoder-cold-start-query.ts @@ -7,10 +7,10 @@ import { getQoderSettings, resolveQoderSettingSources, } from '../config/settings'; -import { - resolveEffortLevel, -} from '../models/model-catalog'; import { toQoderRuntimeModelId } from '../models/model-selection'; +import { + resolveModelReasoningEffort, +} from '../models/qoder-model-config'; import type { QoderHostContext } from '../qoder-host-context'; import { extractAssistantText } from '../services/extract-assistant-text'; import { createCustomSpawnFunction } from './custom-spawn'; @@ -105,7 +105,7 @@ export async function runColdStartQuery( } if (!config.thinking?.disabled) { - const effortLevel = resolveEffortLevel(selectedModel, settings.effortLevel); + const effortLevel = resolveModelReasoningEffort(selectedModel, settings); options.extraArgs = { ...options.extraArgs, 'reasoning-effort': effortLevel }; } diff --git a/src/qoder/runtime/qoder-dynamic-updates.ts b/src/qoder/runtime/qoder-dynamic-updates.ts index 53915c0..b2c4e9c 100644 --- a/src/qoder/runtime/qoder-dynamic-updates.ts +++ b/src/qoder/runtime/qoder-dynamic-updates.ts @@ -6,10 +6,8 @@ import type { import type { McpServerConfig } from '../../core/types'; import type { PermissionMode, QoderianSettings } from '../../core/types/settings'; import type { McpServerManager } from '../mcp/mcp-server-manager'; -import { - resolveEffortLevel, -} from '../models/model-catalog'; import { toQoderRuntimeModelId } from '../models/model-selection'; +import { resolveModelReasoningEffort } from '../models/qoder-model-config'; import type { ClosePersistentQueryOptions, PersistentQueryConfig, @@ -75,7 +73,7 @@ export async function applyQoderDynamicUpdates( } } - const effortLevel = resolveEffortLevel(selectedModel, settings.effortLevel); + const effortLevel = resolveModelReasoningEffort(selectedModel, settings); const currentEffort = deps.getCurrentConfig()?.effortLevel ?? null; if (effortLevel !== currentEffort) { try { diff --git a/src/qoder/runtime/qoder-query-options-builder.ts b/src/qoder/runtime/qoder-query-options-builder.ts index 5c5a6f5..2029449 100644 --- a/src/qoder/runtime/qoder-query-options-builder.ts +++ b/src/qoder/runtime/qoder-query-options-builder.ts @@ -12,11 +12,9 @@ import { resolveQoderSettingSources, } from '../config/settings'; import type { McpServerManager } from '../mcp/mcp-server-manager'; -import { - resolveEffortLevel, -} from '../models/model-catalog'; import { createQoderModelPolicyProvider } from '../models/model-policy'; import { toQoderRuntimeModelId } from '../models/model-selection'; +import { resolveModelReasoningEffort } from '../models/qoder-model-config'; import { buildSystemPrompt, computeSystemPromptKey, @@ -117,7 +115,7 @@ export class QueryOptionsBuilder { return { model: runtimeModel, - effortLevel: resolveEffortLevel(runtimeModel, ctx.settings.effortLevel), + effortLevel: resolveModelReasoningEffort(runtimeModel, ctx.settings), permissionMode: ctx.settings.permissionMode, sdkPermissionMode, systemPromptKey: computeSystemPromptKey(systemPromptSettings), @@ -290,7 +288,7 @@ export class QueryOptionsBuilder { settings: QoderianSettings, model: string ): void { - const effortLevel = resolveEffortLevel(model, settings.effortLevel); + const effortLevel = resolveModelReasoningEffort(model, settings); options.extraArgs = { ...options.extraArgs, 'reasoning-effort': effortLevel, diff --git a/src/style/AGENTS.md b/src/style/AGENTS.md index 1b1e7db..28e34e2 100644 --- a/src/style/AGENTS.md +++ b/src/style/AGENTS.md @@ -6,7 +6,7 @@ src/style/ ├── base/ # container, animations (@keyframes), variables ├── components/ # header, history, messages, code, thinking, toolcalls, status-panel, subagent, input, context-footer, tabs, nav-sidebar -├── toolbar/ # model-selector, thinking-selector, permission-toggle, external-context, mcp-selector +├── toolbar/ # model-selector, permission-toggle, external-context, mcp-selector ├── features/ # file-context, image-context, image-modal, inline-edit, diff, slash-commands, file-link, image-embed, plan-mode, ask-user-question, resume-session ├── modals/ # instruction, mcp-modal, fork-target ├── settings/ # base (shared .qoderian-sp-* panel layout), env-snippets, slash-settings, mcp-settings, plugin-settings, agent-settings diff --git a/src/style/accessibility.css b/src/style/accessibility.css index 9899f1b..d16d16c 100644 --- a/src/style/accessibility.css +++ b/src/style/accessibility.css @@ -6,8 +6,7 @@ .qoderian-subagent-header:focus-visible, .qoderian-input-nav-btn:focus-visible, .qoderian-model-btn:focus-visible, -.qoderian-permission-button:focus-visible, -.qoderian-thinking-current:focus-visible { +.qoderian-permission-button:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; border-radius: 4px; diff --git a/src/style/index.css b/src/style/index.css index 538bef4..53a9377 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -22,7 +22,6 @@ /* Toolbar */ @import "./toolbar/model-selector.css"; -@import "./toolbar/thinking-selector.css"; @import "./toolbar/permission-toggle.css"; @import "./toolbar/external-context.css"; @import "./toolbar/mcp-selector.css"; diff --git a/src/style/toolbar/thinking-selector.css b/src/style/toolbar/thinking-selector.css deleted file mode 100644 index 3ac3fb4..0000000 --- a/src/style/toolbar/thinking-selector.css +++ /dev/null @@ -1,81 +0,0 @@ -/* Qoder reasoning-effort selector. */ -.qoderian-thinking-selector { - display: flex; - align-items: center; - gap: 6px; -} - -.qoderian-thinking-effort { - display: flex; - align-items: center; - gap: 6px; -} - -.qoderian-thinking-label-text { - font-size: 11px; - color: var(--text-muted); -} - -.qoderian-thinking-gears { - position: relative; - display: flex; - align-items: center; - border-radius: 4px; -} - -/* Current selection (visible when collapsed) */ -.qoderian-thinking-current { - padding: 3px 8px; - font-size: 11px; - color: var(--qoderian-brand); - font-weight: 500; - cursor: pointer; - border-radius: 3px; - white-space: nowrap; - background: transparent; -} - -/* Options container - expands vertically upward */ -.qoderian-thinking-options { - position: absolute; - left: 0; - bottom: 100%; - margin-bottom: 0; - display: flex; - flex-direction: column; - gap: 2px; - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 4px; - opacity: 0; - visibility: hidden; - transition: opacity 0.15s ease, visibility 0.15s ease; -} - -/* Expand only after the current effort is clicked. */ -.qoderian-thinking-gears--open .qoderian-thinking-options { - opacity: 1; - visibility: visible; -} - -.qoderian-thinking-gear { - padding: 3px 8px; - font-size: 11px; - color: var(--text-muted); - cursor: pointer; - border-radius: 3px; - transition: background 0.1s ease, color 0.1s ease; - white-space: nowrap; -} - -.qoderian-thinking-gear:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); -} - -.qoderian-thinking-gear.selected { - background: rgba(var(--qoderian-brand-rgb), 0.15); - color: var(--qoderian-brand); - font-weight: 500; -} diff --git a/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts b/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts index 46144dc..f0fe835 100644 --- a/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts @@ -1601,13 +1601,14 @@ describe('QoderChatRuntime', () => { it('updates effort level via applyFlagSettings without restarting', async () => { mockPlugin.settings.model = 'sonnet'; - mockPlugin.settings.effortLevel = 'high'; const chunks1: any[] = []; for await (const c of queryText(service, 'first')) chunks1.push(c); const queryCountBefore = getQueryCallCount(); - mockPlugin.settings.effortLevel = 'max'; + mockPlugin.settings.qoder = { + modelOverrides: { sonnet: { thinkingEffort: 'max' } }, + }; const chunks2: any[] = []; for await (const c of queryText(service, 'second')) chunks2.push(c); diff --git a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts index ea3a845..ab98c56 100644 --- a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts @@ -18,11 +18,9 @@ jest.mock('fs'); function createMockCallbacks() { return { onModelChange: jest.fn(), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn(), getSettings: jest.fn().mockReturnValue({ model: 'haiku', - effortLevel: 'high', permissionMode: 'yolo', }), getEnvironmentVariables: jest.fn().mockReturnValue(''), diff --git a/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts b/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts index 941430e..457537e 100644 --- a/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.model-selector.test.ts @@ -1,6 +1,6 @@ import { createMockEl } from '@test/helpers/mock-element'; -import { EffortSelector, ModelSelector, PermissionToggle } from '@/features/chat/ui/input-toolbar'; +import { ModelSelector, PermissionToggle } from '@/features/chat/ui/input-toolbar'; jest.mock('@/shared/icons', () => ({ QODER_ICON: {}, @@ -14,11 +14,9 @@ describe('ModelSelector', () => { const parentEl = createMockEl(); const callbacks = { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'auto', - effortLevel: 'high', permissionMode: 'acceptEdits', }), getEnvironmentVariables: jest.fn().mockReturnValue(''), @@ -44,11 +42,9 @@ describe('ModelSelector', () => { const parentEl = createMockEl(); const callbacks = { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'qmodel', - effortLevel: 'high', permissionMode: 'acceptEdits', }), getEnvironmentVariables: jest.fn().mockReturnValue(''), @@ -75,11 +71,9 @@ describe('ModelSelector', () => { const parentEl = createMockEl(); const callbacks = { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'auto', - effortLevel: 'high', permissionMode: 'acceptEdits', }), getEnvironmentVariables: jest.fn().mockReturnValue(''), @@ -114,11 +108,9 @@ describe('ModelSelector', () => { const retryRuntimeCatalog = jest.fn().mockResolvedValue(undefined); const callbacks = { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'auto', - effortLevel: 'high', permissionMode: 'acceptEdits', }), getModelConfig: jest.fn().mockReturnValue({ @@ -147,11 +139,9 @@ describe('ModelSelector', () => { const parentEl = createMockEl(); const callbacks = { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'auto', - effortLevel: 'high', permissionMode: 'acceptEdits', }), getModelConfig: jest.fn().mockReturnValue({ @@ -191,13 +181,11 @@ describe('ModelSelector', () => { ) { const settings = { model: 'qmodel', - effortLevel: 'high', permissionMode: 'acceptEdits', qoder: { modelOverrides: overrides }, }; return { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue(settings), getEnvironmentVariables: jest.fn().mockReturnValue(''), @@ -314,7 +302,6 @@ describe('ModelSelector', () => { onModelOverrideChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'qmodel', - effortLevel: 'high', permissionMode: 'acceptEdits', qoder: { modelOverrides: { qmodel: { thinkingEnabled: false } } }, }), @@ -451,7 +438,7 @@ describe('ModelSelector', () => { row.querySelector('.qoderian-model-editor-tier-label')?.textContent )).toEqual(['low', 'medium', 'xhigh']); expect(rows[3].getAttribute('title')).toBe('Minimal reasoning'); - // Global effort 'high' is not in the list → server default is checked. + // No override set → the server default is checked. expect(rows[4].hasClass('selected')).toBe(true); expect(rows[4].getAttribute('aria-selected')).toBe('true'); expect(rows[3].hasClass('selected')).toBe(false); @@ -501,93 +488,15 @@ describe('ModelSelector', () => { expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { thinkingEffort: undefined }); expect(overrides.qmodel?.thinkingEffort).toBeUndefined(); }); - it('persists the server default explicitly when the global effort is offered', async () => { - // DeepSeek-V4-Pro shape (low/high/max), as delivered sorted by the - // getter: global 'high' is offered, max is the server default. - // Clicking max must stick instead of clearing to high. - const deepseekEfforts = [ - { value: 'low', isDefault: false }, - { value: 'high', isDefault: false }, - { value: 'max', isDefault: true }, - ]; - const parentEl = createMockEl(); - const overrides: Record> = {}; - const onModelOverrideChange = jest.fn(async (_model: string, patch: Record) => { - const current = overrides.qmodel ?? {}; - for (const [key, value] of Object.entries(patch)) { - if (value === undefined) delete current[key]; - else current[key] = value; - } - if (Object.keys(current).length > 0) overrides.qmodel = current; - else delete overrides.qmodel; - }); - new ModelSelector(parentEl, buildCallbacks({ onModelOverrideChange }, overrides, deepseekEfforts)); - - parentEl.querySelector('.qoderian-model-edit')?.click(); - // Effective starts at the global 'high'; choosing max persists it. - parentEl.querySelectorAll('.qoderian-model-editor-tier')[5]?.click(); - await Promise.resolve(); - await Promise.resolve(); - expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { thinkingEffort: 'max' }); - expect(parentEl.querySelectorAll('.qoderian-model-editor-tier')[5]?.hasClass('selected')) - .toBe(true); - - // Choosing the fallback value (global high) clears the override. - parentEl.querySelectorAll('.qoderian-model-editor-tier')[4]?.click(); - await Promise.resolve(); - await Promise.resolve(); - expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { thinkingEffort: undefined }); - expect(overrides.qmodel).toBeUndefined(); - }); }); }); }); -describe('EffortSelector', () => { - it('opens only when clicked and closes after choosing an effort', () => { - const parentEl = createMockEl(); - const callbacks = { - onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), - onPermissionModeChange: jest.fn().mockResolvedValue(undefined), - getSettings: jest.fn().mockReturnValue({ - model: 'qmodel', - effortLevel: 'medium', - permissionMode: 'acceptEdits', - }), - getModelConfig: jest.fn().mockReturnValue({ - getReasoningOptions: jest.fn().mockReturnValue([ - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Med' }, - { value: 'high', label: 'High' }, - ]), - getDefaultReasoningValue: jest.fn().mockReturnValue('medium'), - }), - }; - - new EffortSelector(parentEl, callbacks); - - const gears = parentEl.querySelector('.qoderian-thinking-gears'); - const current = parentEl.querySelector('.qoderian-thinking-current'); - const option = parentEl.querySelectorAll('.qoderian-thinking-gear')[0]; - - gears?.dispatchEvent('mouseenter', { type: 'mouseenter' }); - expect(gears?.hasClass('qoderian-thinking-gears--open')).toBe(false); - - current?.click(); - expect(gears?.hasClass('qoderian-thinking-gears--open')).toBe(true); - - option?.click(); - expect(gears?.hasClass('qoderian-thinking-gears--open')).toBe(false); - }); -}); - describe('PermissionToggle', () => { it('renders all SDK permission levels and applies the selected level', async () => { const parentEl = createMockEl(); const settings = { model: 'auto', - effortLevel: 'high', permissionMode: 'default' as const, }; const onPermissionModeChange = jest.fn(async (mode) => { @@ -595,7 +504,6 @@ describe('PermissionToggle', () => { }); const callbacks = { onModelChange: jest.fn().mockResolvedValue(undefined), - onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange, getSettings: jest.fn(() => settings), getModelConfig: jest.fn(), diff --git a/tests/unit/qoder/models/qoder-model-config.test.ts b/tests/unit/qoder/models/qoder-model-config.test.ts index 64ecd98..f3a97f1 100644 --- a/tests/unit/qoder/models/qoder-model-config.test.ts +++ b/tests/unit/qoder/models/qoder-model-config.test.ts @@ -38,14 +38,11 @@ describe('qoderModelConfig', () => { }); describe('applyModelDefaults', () => { - it('clamps stale effort and records a discovered SDK model as the last model', () => { - const settings: Record = { - effortLevel: 'xhigh', - }; + it('records a discovered SDK model as the last model', () => { + const settings: Record = {}; qoderModelConfig.applyModelDefaults('qoder-sonnet-4-5', settings); - expect(settings.effortLevel).toBe('high'); expect(settings.qoder).toEqual(expect.objectContaining({ lastModel: 'qoder-sonnet-4-5' })); }); }); diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 67c4fa8..dd7dabb 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -1692,7 +1692,9 @@ describe('QoderChatRuntime', () => { it('should update effort level when changed for adaptive models', async () => { (mockPlugin as any).settings.model = 'sonnet'; - (mockPlugin as any).settings.effortLevel = 'max'; + (mockPlugin as any).settings.qoder = { + modelOverrides: { sonnet: { thinkingEffort: 'max' } }, + }; await (service as any).applyDynamicUpdates({}); @@ -1702,7 +1704,9 @@ describe('QoderChatRuntime', () => { it('should update effort level for custom model ids', async () => { (mockPlugin as any).settings.model = 'custom-model'; - (mockPlugin as any).settings.effortLevel = 'max'; + (mockPlugin as any).settings.qoder = { + modelOverrides: { 'custom-model': { thinkingEffort: 'max' } }, + }; await (service as any).applyDynamicUpdates({}); @@ -1711,6 +1715,12 @@ describe('QoderChatRuntime', () => { it('should keep effort active when switching from custom to built-in model ids', async () => { (mockPlugin as any).settings.model = 'custom-model'; + (mockPlugin as any).settings.qoder = { + modelOverrides: { + 'custom-model': { thinkingEffort: 'max' }, + sonnet: { thinkingEffort: 'max' }, + }, + }; (service as any).currentConfig = (service as any).buildPersistentQueryConfig( '/mock/vault/path', '/usr/local/bin/qoder', @@ -1722,7 +1732,6 @@ describe('QoderChatRuntime', () => { mockPersistentQuery.applyFlagSettings.mockClear(); (mockPlugin as any).settings.model = 'sonnet'; - (mockPlugin as any).settings.effortLevel = 'max'; const previousQuery = mockPersistentQuery; await (service as any).applyDynamicUpdates({}); @@ -1733,7 +1742,12 @@ describe('QoderChatRuntime', () => { it('should keep effort active when switching from built-in to custom model ids', async () => { (mockPlugin as any).settings.model = 'sonnet'; - (mockPlugin as any).settings.effortLevel = 'max'; + (mockPlugin as any).settings.qoder = { + modelOverrides: { + sonnet: { thinkingEffort: 'max' }, + 'custom-model': { thinkingEffort: 'max' }, + }, + }; (service as any).currentConfig = (service as any).buildPersistentQueryConfig( '/mock/vault/path', '/usr/local/bin/qoder', @@ -1896,7 +1910,9 @@ describe('QoderChatRuntime', () => { it('should silently handle effort level update error', async () => { (mockPlugin as any).settings.model = 'sonnet'; - (mockPlugin as any).settings.effortLevel = 'max'; + (mockPlugin as any).settings.qoder = { + modelOverrides: { sonnet: { thinkingEffort: 'max' } }, + }; mockPersistentQuery.applyFlagSettings.mockRejectedValueOnce(new Error('Effort error')); await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined(); diff --git a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts index 6e17a47..a185a1d 100644 --- a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts +++ b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts @@ -15,7 +15,6 @@ function createContext( ): PersistentQueryContext { const settings = { model: 'auto', - effortLevel: 'medium', permissionMode, mediaFolder: '', systemPrompt: '', diff --git a/tests/unit/qoder/services/qoder-inline-edit-service.test.ts b/tests/unit/qoder/services/qoder-inline-edit-service.test.ts index c6dc0cd..c260e8c 100644 --- a/tests/unit/qoder/services/qoder-inline-edit-service.test.ts +++ b/tests/unit/qoder/services/qoder-inline-edit-service.test.ts @@ -413,7 +413,9 @@ describe('QoderInlineEditService', () => { it('should pass configured Qoder reasoning effort for custom models', async () => { mockPlugin.settings.model = 'custom-model'; - mockPlugin.settings.effortLevel = 'medium'; + mockPlugin.settings.qoder = { + modelOverrides: { 'custom-model': { thinkingEffort: 'medium' } }, + }; service = new QoderInlineEditService(mockPlugin); setMockMessages([ diff --git a/tests/unit/qoder/services/qoder-instruction-refine-service.test.ts b/tests/unit/qoder/services/qoder-instruction-refine-service.test.ts index 5ef541f..9e8f457 100644 --- a/tests/unit/qoder/services/qoder-instruction-refine-service.test.ts +++ b/tests/unit/qoder/services/qoder-instruction-refine-service.test.ts @@ -202,7 +202,9 @@ describe('QoderInstructionRefineService', () => { it('should pass configured Qoder reasoning effort for custom models', async () => { mockPlugin.settings.model = 'custom-model'; - mockPlugin.settings.effortLevel = 'medium'; + mockPlugin.settings.qoder = { + modelOverrides: { 'custom-model': { thinkingEffort: 'medium' } }, + }; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, {