From c1536d29b7a087d4627bfe142024d7e98702e2ab Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Sat, 15 Aug 2026 20:15:34 +0800 Subject: [PATCH] feat: add per-model context and thinking editor mirroring the Qoder IDE - Model dropdown becomes a two-card cascade: the model list stays in the left pane while a per-model editor expands beside the hovered row, with flip/max-height placement as pure tested functions - Editor shows context window tiers (ascending), a thinking on/off toggle, and server thinking effort levels sorted by the canonical intensity scale (low to max), matching the IDE selector - Overrides persist per model in settings and apply at request time via SDK pull-mode resolveModel (contextWindow / reasoningEffort); probe maps context_config / thinking_config and getters normalize stale persisted order on read - Editing a non-selected model no longer rewrites the conversation usage meter; reopening the dropdown (mouse or keyboard) returns to the list view; runtime degradation clears a stale editor card - Edit pencil appears on hover/focus only; i18n keys in 10 locales Co-authored-by: QoderAI (Qwen 3.8 Max) --- .gitignore | 3 + CHANGELOG.md | 6 + src/core/types/services.ts | 7 + src/core/types/settings.ts | 35 ++ src/features/chat/tabs/tab.ts | 34 +- .../ui/toolbar/model-dropdown-placement.ts | 71 ++++ .../chat/ui/toolbar/toolbar-selectors.ts | 294 +++++++++++++- src/i18n/locales/de.json | 8 + src/i18n/locales/en.json | 8 + src/i18n/locales/es.json | 8 + src/i18n/locales/fr.json | 8 + src/i18n/locales/ja.json | 8 + src/i18n/locales/ko.json | 8 + src/i18n/locales/pt.json | 8 + src/i18n/locales/ru.json | 8 + src/i18n/locales/zh-CN.json | 8 + src/i18n/locales/zh-TW.json | 8 + src/i18n/types.ts | 8 + src/qoder/commands/probe-runtime-commands.ts | 49 +++ src/qoder/config/settings.ts | 90 ++++- src/qoder/models/model-catalog.ts | 13 + src/qoder/models/model-options.ts | 3 + src/qoder/models/model-policy.ts | 63 +++ src/qoder/models/qoder-model-config.ts | 49 ++- .../runtime/qoder-query-options-builder.ts | 12 + src/shared/icons.ts | 29 ++ src/style/toolbar/model-selector.css | 253 +++++++++++- tests/helpers/mock-element.ts | 2 + .../ui/input-toolbar.model-selector.test.ts | 371 ++++++++++++++++++ .../chat/ui/model-dropdown-placement.test.ts | 77 ++++ .../commands/probe-runtime-commands.test.ts | 59 +++ .../unit/qoder/config/model-overrides.test.ts | 133 +++++++ tests/unit/qoder/models/model-policy.test.ts | 313 +++++++++++++++ .../qoder/models/qoder-model-config.test.ts | 102 +++++ .../qoder-query-options-builder.test.ts | 68 ++++ 35 files changed, 2205 insertions(+), 19 deletions(-) create mode 100644 src/features/chat/ui/toolbar/model-dropdown-placement.ts create mode 100644 src/qoder/models/model-policy.ts create mode 100644 tests/unit/features/chat/ui/model-dropdown-placement.test.ts create mode 100644 tests/unit/qoder/config/model-overrides.test.ts create mode 100644 tests/unit/qoder/models/model-policy.test.ts diff --git a/.gitignore b/.gitignore index 1e825f3..a0521ba 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ coverage/ # Local preview material, not shipped /docs/preview/ + +# Temporary screenshots from automated UI verification +/.cu_crop_*.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 80542c9..8715ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ version with its date and start a fresh empty `[Unreleased]` above it. China build (`qoderclicn`, config under `~/.qoder-cn`). Auto- detection, session history, global plugins, and login hints all follow the selected edition. +- Per-model context and thinking editor in the model selector, + mirroring the Qoder IDE: hovering a model row reveals an edit + affordance that opens a side editor card with context window + tiers, a thinking on/off toggle, and the model's server-provided + thinking effort levels. Choices persist per model and are applied + to every request. ### Fixed diff --git a/src/core/types/services.ts b/src/core/types/services.ts index 104939e..7efaae2 100644 --- a/src/core/types/services.ts +++ b/src/core/types/services.ts @@ -5,6 +5,7 @@ import type { ManagedMcpServer, PluginInfo, } from './index'; +import type { ModelContextTier, ModelThinkingEffort } from './settings'; // --------------------------------------------------------------------------- // App-level service interfaces // --------------------------------------------------------------------------- @@ -121,6 +122,12 @@ export interface UIOption { priceLabel?: string; /** Trailing promotion badge shown on the option row, e.g. '错峰5折'. */ promotionLabel?: string; + /** Configurable context-window tiers for the model edit panel. */ + contextTiers?: ModelContextTier[]; + /** Whether the model edit panel may offer disabling thinking. */ + thinkingDisableable?: boolean; + /** Configurable thinking effort levels for the model edit panel. */ + thinkingEfforts?: ModelThinkingEffort[]; } export interface PathIconSvg { diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 972ed30..76e3cf1 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -73,6 +73,33 @@ export const QODER_CLI_EDITIONS = ['global', 'cn'] as const; */ export type QoderCliEdition = typeof QODER_CLI_EDITIONS[number]; +/** Server context-window tier, e.g. the 200K/400K/1M editor choices. */ +export interface ModelContextTier { + /** Display tier label from the server, e.g. '200K'. */ + label: string; + tokenCount: number; + isDefault: boolean; +} + +/** Server thinking effort level, e.g. the low/medium/xhigh editor choices. */ +export interface ModelThinkingEffort { + /** Effort value accepted by the CLI, e.g. 'xhigh'. */ + value: string; + isDefault: boolean; + /** Server description of the level, shown as a tooltip in the IDE. */ + description?: string; +} + +/** Per-model editor overrides mirroring the Qoder IDE model edit panel. */ +export interface QoderModelOverride { + /** Selected context-window tier in tokens; absent means server default. */ + contextWindow?: number; + /** False disables thinking for models that support it. */ + thinkingEnabled?: boolean; + /** Per-model reasoning effort; absent means server default. */ + thinkingEffort?: string; +} + /** Qoder CLI settings stored alongside Qoderian's general preferences. */ export interface QoderSettings { cliPath: string; @@ -91,6 +118,12 @@ export interface QoderSettings { priceFactor?: number; /** Pre-discount multiplier, when the server prices this model down. */ originalPriceFactor?: number; + /** Configurable context-window tiers reported by the server. */ + contextTiers?: ModelContextTier[]; + /** Whether the server allows explicitly disabling thinking. */ + thinkingDisableable?: boolean; + /** Configurable thinking effort levels reported by the server. */ + thinkingEfforts?: ModelThinkingEffort[]; promotion?: { active?: boolean; /** Server-localized badge text keyed by `en` / `zh`. */ @@ -107,6 +140,8 @@ export interface QoderSettings { model?: string; }>; lastModel: string; + /** Per-model editor overrides keyed by runtime model id. */ + modelOverrides: Record; } /** diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index ca180af..8c7e507 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -6,9 +6,10 @@ import { getEnhancedPath } from '../../../core/env/environment'; import { getVaultPath } from '../../../core/fs/path'; import type { ChatRuntime } from '../../../core/runtime/chat-runtime'; import type { ChatMessage, Conversation, QoderState } from '../../../core/types'; +import type { QoderModelOverride } from '../../../core/types/settings'; import { t } from '../../../i18n/i18n'; import type QoderianPlugin from '../../../main'; -import { getQoderSettings } from '../../../qoder/config/settings'; +import { getQoderSettings, updateQoderSettings } from '../../../qoder/config/settings'; import { SlashCommandDropdown, toSlashCommandDropdownEntries, @@ -431,6 +432,37 @@ function initializeInputToolbar( settings.effortLevel = effort; }); }, + onModelOverrideChange: async (model: string, override: Partial) => { + await updateTabQoderSettings(tab, plugin, (settings) => { + const current = getQoderSettings(settings).modelOverrides; + const merged: QoderModelOverride = { ...current[model] }; + for (const [key, value] of Object.entries(override)) { + if (value === undefined) { + delete merged[key as keyof QoderModelOverride]; + } else { + (merged as Record)[key] = value; + } + } + const next = { ...current }; + if (Object.keys(merged).length > 0) next[model] = merged; + else delete next[model]; + updateQoderSettings(settings, { modelOverrides: next }); + }); + + // The context meter tracks the effective window of the edited model. + // Overrides for other models must not rewrite this conversation's + // usage, which belongs to the currently selected model. + const currentUsage = tab.state.usage; + if (currentUsage && model === plugin.settings.model) { + const modelConfig = getTabModelConfig(tab, plugin); + const newContextWindow = modelConfig.getEffectiveContextWindowSize( + model, + plugin.settings, + ); + tab.state.usage = recalculateUsageForModel(currentUsage, model, newContextWindow); + tab.ui.contextUsageMeter?.update(tab.state.usage); + } + }, onPermissionModeChange: async (mode) => { await updateTabQoderSettings(tab, plugin, (settings) => { settings.permissionMode = mode; diff --git a/src/features/chat/ui/toolbar/model-dropdown-placement.ts b/src/features/chat/ui/toolbar/model-dropdown-placement.ts new file mode 100644 index 0000000..2d09ae9 --- /dev/null +++ b/src/features/chat/ui/toolbar/model-dropdown-placement.ts @@ -0,0 +1,71 @@ +/** + * Pure placement decisions for the model dropdown cascade panel. + * + * The rules mirror the flip/size middleware of floating-positioning + * libraries: prefer the anchor's inline-start edge and flip only when the + * panel would overflow the viewport there, and cap the panel height to the + * space available above the toolbar. Keeping the decisions pure (no DOM + * access) makes them unit-testable without a layout engine; the selector + * component only measures and applies the results. + */ + +export interface ModelDropdownAnchorRect { + left: number; + right: number; + top: number; +} + +/** Breathing room kept between the panel and the viewport edge. */ +export const MODEL_DROPDOWN_VIEWPORT_MARGIN = 8; +/** Repo-wide popover height convention (see nav TOC popover). */ +export const MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT = 420; +/** Floor so the panel stays usable even in very short windows. */ +export const MODEL_DROPDOWN_MIN_HEIGHT = 160; + +/** + * Decides whether the panel should anchor to the inline-end edge of its + * toolbar instead of the inline-start edge. Start alignment keeps the panel + * flush with the trigger button; flipping is a last resort for anchors whose + * start side cannot fit the panel. + */ +export function shouldFlipModelDropdown( + anchor: ModelDropdownAnchorRect, + panelWidth: number, + viewportWidth: number, +): boolean { + const margin = MODEL_DROPDOWN_VIEWPORT_MARGIN; + const fitsAtStart = anchor.left + panelWidth <= viewportWidth - margin; + const fitsAtEnd = anchor.right - panelWidth >= margin; + return !fitsAtStart && fitsAtEnd; +} + +/** + * Caps the panel to the vertical space above the toolbar so the upward + * growing dropdown never covers the viewport top, without exceeding the + * repo-wide 420px popover convention. + */ +export function modelDropdownMaxHeight(anchorTop: number): number { + const available = anchorTop - MODEL_DROPDOWN_VIEWPORT_MARGIN; + if (!Number.isFinite(available)) return MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT; + return Math.max( + MODEL_DROPDOWN_MIN_HEIGHT, + Math.min(available, MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT), + ); +} + +/** + * Anchors the compact editor card to its edited row like an IDE flyout, + * clamped so the card never escapes the visible list area. + */ +export function modelEditorPaneOffset( + rowVisibleTop: number, + editorHeight: number, + listVisibleHeight: number, +): number { + if (!Number.isFinite(rowVisibleTop) + || !Number.isFinite(editorHeight) + || !Number.isFinite(listVisibleHeight)) { + return 0; + } + return Math.max(0, Math.min(rowVisibleTop, listVisibleHeight - editorHeight)); +} diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index a1bebfc..893769c 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -1,11 +1,24 @@ import { Notice } from 'obsidian'; import type { QoderRuntimeStatus } from '../../../../core/types/services'; -import type { PermissionMode } from '../../../../core/types/settings'; +import type { PermissionMode, QoderModelOverride } from '../../../../core/types/settings'; +import { t } from '../../../../i18n/i18n'; import { getActiveQoderCliEdition, getQoderCliLoginCommand } from '../../../../qoder/config/cli-edition'; +import { getQoderModelOverride } from '../../../../qoder/config/settings'; import type { QoderModelConfig } from '../../../../qoder/models/qoder-model-config'; -import { createIconSvg, QODER_ICON } from '../../../../shared/icons'; +import { + CHECK_ICON, + CHEVRON_LEFT_ICON, + createIconSvg, + PENCIL_ICON, + QODER_ICON, +} from '../../../../shared/icons'; import { ClickPopover } from './click-popover'; +import { + modelDropdownMaxHeight, + modelEditorPaneOffset, + shouldFlipModelDropdown, +} from './model-dropdown-placement'; function runToolbarAction(action: () => Promise, failureMessage: string): void { void action().catch(() => { @@ -24,6 +37,8 @@ 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; getSettings: () => ToolbarSettings; getModelConfig: () => QoderModelConfig; getRuntimeStatus?: () => QoderRuntimeStatus; @@ -58,7 +73,10 @@ export class ModelSelector { private readonly container: HTMLElement; private buttonEl: HTMLElement | null = null; private dropdownEl: HTMLElement | null = null; + private listPaneEl: HTMLElement | null = null; + private editorPaneEl: HTMLElement | null = null; private popover: ClickPopover | null = null; + private editingModel: string | null = null; private unsubscribeRuntimeStatus: (() => void) | null = null; constructor(parentEl: HTMLElement, private readonly callbacks: ToolbarCallbacks) { @@ -91,6 +109,10 @@ export class ModelSelector { this.updateDisplay(); this.dropdownEl = this.container.createDiv({ cls: 'qoderian-model-dropdown' }); + // IDE-style cascade: the model list stays visible in the left pane while + // the per-model editor expands into the right pane. + this.listPaneEl = this.dropdownEl.createDiv({ cls: 'qoderian-model-list-pane' }); + this.editorPaneEl = this.dropdownEl.createDiv({ cls: 'qoderian-model-editor-pane' }); this.renderOptions(); this.popover = new ClickPopover( this.container, @@ -98,6 +120,23 @@ export class ModelSelector { this.dropdownEl, 'qoderian-model-selector--open', ); + // Reopening the dropdown always returns to the model list view. The + // popover toggles first, so the open class already reflects the new state. + const resetEditingView = () => { + if (this.editingModel !== null + && this.container.hasClass('qoderian-model-selector--open')) { + this.editingModel = null; + this.renderOptions(); + } + // Re-measure on every open/close so a resized window cannot leave a + // stale placement behind. + this.updateDropdownPlacement(); + }; + this.buttonEl.addEventListener('click', resetEditingView); + this.buttonEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + resetEditingView(); + }); } updateDisplay(): void { @@ -122,8 +161,12 @@ export class ModelSelector { } renderOptions(): void { - if (!this.dropdownEl) return; - this.dropdownEl.empty(); + const listPaneEl = this.listPaneEl; + const editorPaneEl = this.editorPaneEl; + if (!this.dropdownEl || !listPaneEl || !editorPaneEl) return; + // Rebuilding the list must not yank the reader back to the top. + const previousScrollTop = listPaneEl.scrollTop; + listPaneEl.empty(); const currentModel = this.callbacks.getSettings().model; const models = this.getAvailableModels(); @@ -131,7 +174,7 @@ export class ModelSelector { let lastGroup: string | undefined; if (status.kind !== 'ready') { - const statusEl = this.dropdownEl.createDiv({ cls: 'qoderian-model-runtime-status' }); + const statusEl = listPaneEl.createDiv({ cls: 'qoderian-model-runtime-status' }); statusEl.createDiv({ cls: 'qoderian-model-runtime-title', text: getRuntimeStatusLabel(status) }); statusEl.createDiv({ cls: 'qoderian-model-runtime-message', text: status.message }); if (status.kind === 'authRequired') { @@ -161,21 +204,28 @@ export class ModelSelector { } const shouldRenderModels = status.kind === 'ready' || canUseCachedModels(status); - if (!shouldRenderModels) return; + if (!shouldRenderModels) { + // Degrading mid-edit must not strand the editor card behind the status view. + this.editingModel = null; + editorPaneEl.empty(); + this.dropdownEl.removeClass('qoderian-model-dropdown--editing'); + return; + } for (const model of [...models].reverse()) { if (model.group && model.group !== lastGroup) { - this.dropdownEl.createDiv({ + listPaneEl.createDiv({ cls: 'qoderian-model-group', text: model.group, }); lastGroup = model.group; } - const option = this.dropdownEl.createDiv({ cls: 'qoderian-model-option' }); + const option = listPaneEl.createDiv({ cls: 'qoderian-model-option' }); option.setAttribute('role', 'option'); option.setAttribute('aria-selected', String(model.value === currentModel)); if (model.value === currentModel) option.addClass('selected'); + if (model.value === this.editingModel) option.addClass('editing'); option.appendChild(createIconSvg(QODER_ICON, { className: 'qoderian-model-icon', @@ -193,6 +243,34 @@ export class ModelSelector { meta.createSpan({ cls: 'qoderian-model-price', text: model.priceLabel }); } } + const editable = ((model.contextTiers?.length ?? 0) > 0 + || (model.thinkingEfforts?.length ?? 0) > 0 + || model.thinkingDisableable === true) + && this.callbacks.onModelOverrideChange !== undefined; + if (editable) { + const editEl = option.createDiv({ cls: 'qoderian-model-edit' }); + editEl.setAttribute('role', 'button'); + editEl.setAttribute('tabindex', '0'); + editEl.setAttribute('aria-label', `${t('model.edit')} ${model.label}`); + editEl.appendChild(createIconSvg(PENCIL_ICON, { + className: 'qoderian-model-edit-icon', + height: 11, + ownerDocument: option.ownerDocument, + width: 11, + })); + editEl.createSpan({ cls: 'qoderian-model-edit-label', text: t('model.edit') }); + const enterEditing = (event: Event) => { + event.stopPropagation(); + this.editingModel = model.value; + this.renderOptions(); + }; + editEl.addEventListener('click', enterEditing); + editEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + enterEditing(event); + }); + } if (model.description) option.setAttribute('title', model.description); option.addEventListener('click', (event) => { @@ -205,6 +283,206 @@ export class ModelSelector { }, 'Failed to change model'); }); } + + // The editor pane expands beside the list, mirroring the Qoder IDE. + if (this.editingModel !== null) { + this.renderEditor(this.editingModel); + this.dropdownEl.addClass('qoderian-model-dropdown--editing'); + } else { + editorPaneEl.empty(); + this.dropdownEl.removeClass('qoderian-model-dropdown--editing'); + } + listPaneEl.scrollTop = previousScrollTop; + this.updateDropdownPlacement(); + } + + /** + * Places the dropdown against the real viewport: stay flush with the + * trigger (inline-start) and flip only when the panel would overflow, + * capping the height to the space above the toolbar. The decision rules + * live in model-dropdown-placement.ts so they stay unit-testable. + */ + private updateDropdownPlacement(): void { + const dropdownEl = this.dropdownEl; + if (!dropdownEl) return; + const view = this.container.ownerDocument.defaultView; + if (!view) return; + const anchor = (dropdownEl.offsetParent ?? this.container).getBoundingClientRect(); + dropdownEl.toggleClass( + 'qoderian-model-dropdown--flip', + shouldFlipModelDropdown(anchor, dropdownEl.offsetWidth, view.innerWidth), + ); + dropdownEl.setCssProps({ + '--qoderian-model-dropdown-max-height': `${modelDropdownMaxHeight(anchor.top)}px`, + }); + // Anchor the compact editor card to its edited row, IDE flyout style. + if (this.editingModel !== null && this.listPaneEl && this.editorPaneEl) { + const rowEl = this.listPaneEl.querySelector('.qoderian-model-option.editing'); + const rowVisibleTop = rowEl + ? rowEl.getBoundingClientRect().top - this.listPaneEl.getBoundingClientRect().top + : 0; + this.editorPaneEl.setCssProps({ + '--qoderian-model-editor-offset': `${modelEditorPaneOffset( + rowVisibleTop, + this.editorPaneEl.offsetHeight, + this.listPaneEl.clientHeight, + )}px`, + }); + } + } + + /** IDE-style per-model editor: context window tiers + thinking toggle. */ + private renderEditor(modelValue: string): void { + const editorPaneEl = this.editorPaneEl; + if (!editorPaneEl) return; + editorPaneEl.empty(); + + const settings = this.callbacks.getSettings(); + const modelConfig = this.callbacks.getModelConfig(); + const model = this.getAvailableModels().find(candidate => candidate.value === modelValue); + + const head = editorPaneEl.createDiv({ cls: 'qoderian-model-editor-head' }); + const backEl = head.createDiv({ cls: 'qoderian-model-editor-back' }); + backEl.setAttribute('role', 'button'); + backEl.setAttribute('tabindex', '0'); + backEl.setAttribute('aria-label', t('model.backToList')); + backEl.appendChild(createIconSvg(CHEVRON_LEFT_ICON, { + className: 'qoderian-model-editor-back-icon', + height: 12, + ownerDocument: editorPaneEl.ownerDocument, + width: 12, + })); + const exitEditing = (event: Event) => { + event.stopPropagation(); + this.editingModel = null; + this.renderOptions(); + }; + backEl.addEventListener('click', exitEditing); + backEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + exitEditing(event); + }); + head.createSpan({ cls: 'qoderian-model-editor-title', text: model?.label ?? modelValue }); + + const tiers = modelConfig.getModelContextTiers(modelValue, settings); + if (tiers.length > 0) { + editorPaneEl.createDiv({ + cls: 'qoderian-model-editor-section', + text: t('model.contextWindow'), + }); + const effectiveWindow = modelConfig.getEffectiveContextWindowSize(modelValue, settings); + for (const tier of tiers) { + const selected = tier.tokenCount === effectiveWindow; + const row = editorPaneEl.createDiv({ cls: 'qoderian-model-editor-tier' }); + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', String(selected)); + if (selected) row.addClass('selected'); + row.createSpan({ cls: 'qoderian-model-editor-tier-label', text: tier.label }); + if (tier.isDefault) { + row.createSpan({ + cls: 'qoderian-model-editor-tier-default', + text: t('model.default'), + }); + } + if (selected) { + row.appendChild(createIconSvg(CHECK_ICON, { + className: 'qoderian-model-editor-check', + height: 12, + ownerDocument: editorPaneEl.ownerDocument, + width: 12, + })); + } + row.addEventListener('click', (event) => { + event.stopPropagation(); + if (selected) return; + runToolbarAction(async () => { + await this.callbacks.onModelOverrideChange?.(modelValue, { + // Choosing the server default clears the override entirely. + contextWindow: tier.isDefault ? undefined : tier.tokenCount, + }); + this.renderOptions(); + }, 'Failed to update model settings'); + }); + } + } + + if (modelConfig.isThinkingDisableable(modelValue, settings)) { + const override = getQoderModelOverride(settings, modelValue); + const enabled = override?.thinkingEnabled !== false; + const row = editorPaneEl.createDiv({ cls: 'qoderian-model-editor-toggle-row' }); + row.createSpan({ cls: 'qoderian-model-editor-toggle-label', text: t('model.thinkingMode') }); + const toggleEl = row.createDiv({ cls: 'qoderian-model-editor-toggle' }); + toggleEl.setAttribute('role', 'switch'); + toggleEl.setAttribute('aria-checked', String(enabled)); + if (enabled) toggleEl.addClass('is-on'); + toggleEl.addEventListener('click', (event) => { + event.stopPropagation(); + runToolbarAction(async () => { + await this.callbacks.onModelOverrideChange?.(modelValue, { + thinkingEnabled: !enabled, + }); + this.renderOptions(); + }, 'Failed to update model settings'); + }); + } + + // Server-reported intensity levels render under the switch like the IDE: + // models without efforts keep the bare on/off toggle. + const efforts = modelConfig.getModelThinkingEfforts(modelValue, settings); + const override = getQoderModelOverride(settings, modelValue); + if (override?.thinkingEnabled !== false && efforts.length > 0) { + editorPaneEl.createDiv({ + cls: 'qoderian-model-editor-section qoderian-model-editor-section--divided', + text: t('model.thinkingEffort'), + }); + 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; + const effectiveEffort = override?.thinkingEffort ?? fallbackEffort; + for (const effort of efforts) { + const selected = effort.value === effectiveEffort; + const row = editorPaneEl.createDiv({ cls: 'qoderian-model-editor-tier' }); + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', String(selected)); + if (selected) row.addClass('selected'); + row.createSpan({ cls: 'qoderian-model-editor-tier-label', text: effort.value }); + if (effort.isDefault) { + row.createSpan({ + cls: 'qoderian-model-editor-tier-default', + text: t('model.default'), + }); + } + if (selected) { + row.appendChild(createIconSvg(CHECK_ICON, { + className: 'qoderian-model-editor-check', + height: 12, + ownerDocument: editorPaneEl.ownerDocument, + width: 12, + })); + } + if (effort.description) row.setAttribute('title', effort.description); + row.addEventListener('click', (event) => { + event.stopPropagation(); + if (selected) return; + runToolbarAction(async () => { + await this.callbacks.onModelOverrideChange?.(modelValue, { + // Clearing only when the fallback already yields this value keeps + // explicit choices (incl. the server default) effective. + thinkingEffort: effort.value === fallbackEffort ? undefined : effort.value, + }); + this.renderOptions(); + }, 'Failed to update model settings'); + }); + } + } } } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 464b556..ec8289d 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -287,5 +287,13 @@ "left": "{count} übrig", "trigger": "Nutzung - {percent}%", "unavailable": "Nutzung nicht verfügbar" + }, + "model": { + "edit": "Bearbeiten", + "contextWindow": "Kontextfenster", + "thinkingMode": "Denkmodus", + "thinkingEffort": "Thinking Effort", + "default": "Standard", + "backToList": "Zurück zur Modellliste" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 35fd6c9..f59186b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -287,5 +287,13 @@ "left": "{count} left", "trigger": "Usage - {percent}%", "unavailable": "Usage unavailable" + }, + "model": { + "edit": "Edit", + "contextWindow": "Context", + "thinkingMode": "Thinking", + "thinkingEffort": "Thinking Effort", + "default": "Default", + "backToList": "Back to model list" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 8f9fad6..2de1e2b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -287,5 +287,13 @@ "left": "{count} restantes", "trigger": "Uso - {percent}%", "unavailable": "Uso no disponible" + }, + "model": { + "edit": "Editar", + "contextWindow": "Ventana de contexto", + "thinkingMode": "Modo de pensamiento", + "thinkingEffort": "Thinking Effort", + "default": "Predeterminado", + "backToList": "Volver a la lista de modelos" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1da0976..98c8346 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -287,5 +287,13 @@ "left": "{count} restants", "trigger": "Utilisation - {percent}%", "unavailable": "Utilisation indisponible" + }, + "model": { + "edit": "Modifier", + "contextWindow": "Fenêtre de contexte", + "thinkingMode": "Mode de réflexion", + "thinkingEffort": "Thinking Effort", + "default": "Par défaut", + "backToList": "Retour à la liste des modèles" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6611924..3c2e077 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -287,5 +287,13 @@ "left": "残り {count}", "trigger": "利用量 - {percent}%", "unavailable": "利用量情報を取得できません" + }, + "model": { + "edit": "編集", + "contextWindow": "コンテキストウィンドウ", + "thinkingMode": "思考モード", + "thinkingEffort": "Thinking Effort", + "default": "デフォルト", + "backToList": "モデルリストに戻る" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index b4c1742..924c839 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -287,5 +287,13 @@ "left": "{count} 남음", "trigger": "사용량 - {percent}%", "unavailable": "사용량을 확인할 수 없음" + }, + "model": { + "edit": "편집", + "contextWindow": "컨텍스트 윈도우", + "thinkingMode": "사고 모드", + "thinkingEffort": "Thinking Effort", + "default": "기본", + "backToList": "모델 목록으로 돌아가기" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9216a04..4b21da9 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -287,5 +287,13 @@ "left": "{count} restantes", "trigger": "Uso - {percent}%", "unavailable": "Uso indisponível" + }, + "model": { + "edit": "Editar", + "contextWindow": "Janela de contexto", + "thinkingMode": "Modo de pensamento", + "thinkingEffort": "Thinking Effort", + "default": "Padrão", + "backToList": "Voltar à lista de modelos" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a8d54be..a79b7b0 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -287,5 +287,13 @@ "left": "осталось {count}", "trigger": "Использование - {percent}%", "unavailable": "Использование недоступно" + }, + "model": { + "edit": "Редактировать", + "contextWindow": "Контекстное окно", + "thinkingMode": "Режим размышлений", + "thinkingEffort": "Thinking Effort", + "default": "По умолчанию", + "backToList": "Назад к списку моделей" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9406b85..61c6f87 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -287,5 +287,13 @@ "left": "剩余 {count}", "trigger": "用量 - {percent}%", "unavailable": "用量暂不可用" + }, + "model": { + "edit": "编辑", + "contextWindow": "上下文窗口", + "thinkingMode": "思考模式", + "thinkingEffort": "Thinking Effort", + "default": "默认", + "backToList": "返回模型列表" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 4d6f874..8090038 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -287,5 +287,13 @@ "left": "剩餘 {count}", "trigger": "用量 - {percent}%", "unavailable": "用量暫不可用" + }, + "model": { + "edit": "編輯", + "contextWindow": "上下文視窗", + "thinkingMode": "思考模式", + "thinkingEffort": "Thinking Effort", + "default": "預設", + "backToList": "返回模型列表" } } diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 0e24778..196aa7f 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -77,6 +77,14 @@ export type TranslationKey = | 'credits.trigger' | 'credits.unavailable' + // Model editor panel + | 'model.edit' + | 'model.contextWindow' + | 'model.thinkingMode' + | 'model.thinkingEffort' + | 'model.default' + | 'model.backToList' + // Chat - Conversation | 'chat.conversation.emptyPreview' | 'chat.conversation.deleteFailed' diff --git a/src/qoder/commands/probe-runtime-commands.ts b/src/qoder/commands/probe-runtime-commands.ts index a52f1ed..570a92e 100644 --- a/src/qoder/commands/probe-runtime-commands.ts +++ b/src/qoder/commands/probe-runtime-commands.ts @@ -16,6 +16,7 @@ import { type QoderDiscoveredModel, resolveQoderSettingSources, } from '../config/settings'; +import { sortThinkingEfforts } from '../models/model-catalog'; import type { QoderHostContext } from '../qoder-host-context'; import { createCustomSpawnFunction } from '../runtime/custom-spawn'; @@ -92,6 +93,8 @@ function mapSdkModels(models: ModelInfo[]): QoderDiscoveredModel[] { if (!value || model.isEnabled === false) return []; const group = resolveSdkModelGroup(model); const promotion = mapSdkPromotion(model); + const contextTiers = mapContextTiers(model.context_config); + const thinkingEfforts = mapThinkingEfforts(model.thinking_config); return [{ value, displayName: model.displayName?.trim() || value, @@ -102,10 +105,56 @@ function mapSdkModels(models: ModelInfo[]): QoderDiscoveredModel[] { ? { originalPriceFactor: model.originalPriceFactor } : {}), ...(promotion ? { promotion } : {}), + ...(contextTiers ? { contextTiers } : {}), + ...(model.thinking_config?.disabled !== undefined ? { thinkingDisableable: true } : {}), + ...(thinkingEfforts ? { thinkingEfforts } : {}), }]; }); } +/** Server `thinking_config.enabled.efforts` into editor intensity choices. */ +function mapThinkingEfforts( + config: ModelInfo['thinking_config'], +): QoderDiscoveredModel['thinkingEfforts'] { + const efforts = config?.enabled?.efforts; + if (!efforts) return undefined; + + const mapped = Object.entries(efforts).flatMap(([value, entry]) => { + const trimmed = value.trim(); + if (!trimmed) return []; + return [{ + value: trimmed, + isDefault: entry?.is_default === true, + ...(typeof entry?.description === 'string' && entry.description.trim() + ? { description: entry.description.trim() } + : {}), + }]; + }); + + // Server key order is arbitrary; the editor shows efforts weakest-first. + return mapped.length > 0 ? sortThinkingEfforts(mapped) : undefined; +} + +/** Server `context_config` tiers (label → token_count) into editor choices. */ +function mapContextTiers( + config: ModelInfo['context_config'], +): QoderDiscoveredModel['contextTiers'] { + if (!config) return undefined; + + const tiers = Object.entries(config).flatMap(([label, entry]) => { + const tokenCount = entry?.token_count; + const trimmed = label.trim(); + if (!trimmed || typeof tokenCount !== 'number' || !isFinite(tokenCount) || tokenCount <= 0) { + return []; + } + return [{ label: trimmed, tokenCount, isDefault: entry.is_default === true }]; + }); + // Server key order is arbitrary; the editor shows tiers smallest-first. + tiers.sort((a, b) => a.tokenCount - b.tokenCount); + + return tiers.length > 0 ? tiers : undefined; +} + export class ProbeInput implements AsyncIterable { private resolveEnd: (() => void) | null = null; private readonly ended = new Promise((resolve) => { diff --git a/src/qoder/config/settings.ts b/src/qoder/config/settings.ts index 01140e1..3b0a4a2 100644 --- a/src/qoder/config/settings.ts +++ b/src/qoder/config/settings.ts @@ -1,4 +1,10 @@ -import type { HostnameCliPaths, QoderSettings } from '../../core/types/settings'; +import type { + HostnameCliPaths, + ModelContextTier, + ModelThinkingEffort, + QoderModelOverride, + QoderSettings, +} from '../../core/types/settings'; import { normalizeQoderCliEdition } from './cli-edition'; export type QoderSettingSource = 'user' | 'project' | 'local'; @@ -11,6 +17,12 @@ export interface QoderDiscoveredModel { group?: string; priceFactor?: number; originalPriceFactor?: number; + /** Configurable context-window tiers reported by the server. */ + contextTiers?: ModelContextTier[]; + /** Whether the server allows explicitly disabling thinking. */ + thinkingDisableable?: boolean; + /** Configurable thinking effort levels reported by the server. */ + thinkingEfforts?: ModelThinkingEffort[]; promotion?: { active?: boolean; badge?: Record; @@ -35,6 +47,7 @@ export const DEFAULT_QODER_SETTINGS: Readonly = Object.freeze({ discoveredModels: [], discoveredAgents: [], lastModel: 'auto', + modelOverrides: {}, }); function normalizeHostnameCliPaths(value: unknown): HostnameCliPaths { @@ -68,6 +81,67 @@ function normalizeLocalizedBadge(value: unknown): Record | undef return Object.keys(badge).length > 0 ? badge : undefined; } +function normalizeContextTiers(value: unknown): ModelContextTier[] | undefined { + if (!Array.isArray(value)) return undefined; + + const tiers = value.flatMap((entry): ModelContextTier[] => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []; + const record = entry as Record; + const label = typeof record.label === 'string' ? record.label.trim() : ''; + const tokenCount = normalizeFiniteNumber(record.tokenCount); + if (!label || tokenCount === undefined || tokenCount === 0) return []; + return [{ label, tokenCount, isDefault: record.isDefault === true }]; + }); + + return tiers.length > 0 ? tiers : undefined; +} + +function normalizeThinkingEfforts(value: unknown): ModelThinkingEffort[] | undefined { + if (!Array.isArray(value)) return undefined; + + const seen = new Set(); + const efforts = value.flatMap((entry): ModelThinkingEffort[] => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []; + const record = entry as Record; + const effortValue = typeof record.value === 'string' ? record.value.trim() : ''; + if (!effortValue || seen.has(effortValue)) return []; + seen.add(effortValue); + return [{ + value: effortValue, + isDefault: record.isDefault === true, + ...(typeof record.description === 'string' && record.description.trim() + ? { description: record.description.trim() } + : {}), + }]; + }); + + return efforts.length > 0 ? efforts : undefined; +} + +function normalizeModelOverride(value: unknown): QoderModelOverride | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const override: QoderModelOverride = {}; + const contextWindow = normalizeFiniteNumber(record.contextWindow); + if (contextWindow !== undefined && contextWindow > 0) override.contextWindow = contextWindow; + if (typeof record.thinkingEnabled === 'boolean') override.thinkingEnabled = record.thinkingEnabled; + if (typeof record.thinkingEffort === 'string' && record.thinkingEffort.trim()) { + override.thinkingEffort = record.thinkingEffort.trim(); + } + return Object.keys(override).length > 0 ? override : undefined; +} + +function normalizeModelOverrides(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + + const result: Record = {}; + for (const [model, override] of Object.entries(value as Record)) { + const normalized = normalizeModelOverride(override); + if (normalized) result[model] = normalized; + } + return result; +} + function normalizeQoderModelPromotion(value: unknown): QoderDiscoveredModel['promotion'] { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; @@ -108,6 +182,8 @@ function normalizeQoderDiscoveredModels(value: unknown): QoderDiscoveredModel[] const priceFactor = normalizeFiniteNumber(record.priceFactor); const originalPriceFactor = normalizeFiniteNumber(record.originalPriceFactor); const promotion = normalizeQoderModelPromotion(record.promotion); + const contextTiers = normalizeContextTiers(record.contextTiers); + const thinkingEfforts = normalizeThinkingEfforts(record.thinkingEfforts); return [{ value: modelId, displayName, @@ -116,6 +192,9 @@ function normalizeQoderDiscoveredModels(value: unknown): QoderDiscoveredModel[] ...(priceFactor !== undefined ? { priceFactor } : {}), ...(originalPriceFactor !== undefined ? { originalPriceFactor } : {}), ...(promotion ? { promotion } : {}), + ...(contextTiers ? { contextTiers } : {}), + ...(record.thinkingDisableable === true ? { thinkingDisableable: true } : {}), + ...(thinkingEfforts ? { thinkingEfforts } : {}), }]; }); } @@ -162,9 +241,18 @@ export function getQoderSettings( discoveredModels: normalizeQoderDiscoveredModels(config.discoveredModels), discoveredAgents: normalizeQoderDiscoveredAgents(config.discoveredAgents), lastModel: (config.lastModel as string | undefined) ?? DEFAULT_QODER_SETTINGS.lastModel, + modelOverrides: normalizeModelOverrides(config.modelOverrides), }; } +/** Editor override for a model, when the user customized it. */ +export function getQoderModelOverride( + settings: Record, + model: string, +): QoderModelOverride | undefined { + return getQoderSettings(settings).modelOverrides[model]; +} + export function resolveQoderSettingSources( loadUserSettings: boolean, ): QoderSettingSource[] { diff --git a/src/qoder/models/model-catalog.ts b/src/qoder/models/model-catalog.ts index c2a3a6b..2a6a0ad 100644 --- a/src/qoder/models/model-catalog.ts +++ b/src/qoder/models/model-catalog.ts @@ -18,6 +18,19 @@ export const EFFORT_LEVELS: { value: EffortLevel; label: string }[] = [ { value: 'max', label: 'Max' }, ]; +/** + * Order server effort keys by the canonical intensity scale (matching the + * Qoder IDE selector). Unknown keys keep their server order at the end. + */ +export function sortThinkingEfforts(efforts: T[]): T[] { + const rank = new Map( + EFFORT_LEVELS.map((level, index) => [level.value, index]), + ); + return [...efforts].sort((a, b) => + (rank.get(a.value) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(b.value) ?? Number.MAX_SAFE_INTEGER)); +} + /** Default effort level per model tier. */ export const DEFAULT_EFFORT_LEVEL: Record = { 'auto': 'high', diff --git a/src/qoder/models/model-options.ts b/src/qoder/models/model-options.ts index bcaa82b..9e64d59 100644 --- a/src/qoder/models/model-options.ts +++ b/src/qoder/models/model-options.ts @@ -18,6 +18,9 @@ export function getQoderModelOptions(settings: Record): UIOptio group: model.group ?? QODER_MODEL_GROUP, ...(priceLabel ? { priceLabel } : {}), ...(promotionLabel ? { promotionLabel } : {}), + ...(model.contextTiers ? { contextTiers: model.contextTiers } : {}), + ...(model.thinkingDisableable ? { thinkingDisableable: true } : {}), + ...(model.thinkingEfforts ? { thinkingEfforts: model.thinkingEfforts } : {}), }; }); } diff --git a/src/qoder/models/model-policy.ts b/src/qoder/models/model-policy.ts new file mode 100644 index 0000000..b14e857 --- /dev/null +++ b/src/qoder/models/model-policy.ts @@ -0,0 +1,63 @@ +import type { + ModelPolicyContext, + ModelPolicyResult, +} from '@qoder-ai/qoder-agent-sdk'; + +import { getQoderSettings } from '../config/settings'; +import { toQoderRuntimeModelId } from './model-selection'; + +/** + * Pull-mode model policy provider factory. + * + * Registered through `Options.resolveModel`, the CLI asks for the model + * before every LLM call (purposes include `main` and `compact`). The + * provider returns the selected model plus per-request parameters carrying + * the per-model editor overrides: a context-window tier (`contextWindow`), + * disabled thinking (`reasoningEffort: 'none'`) or a per-model reasoning + * effort, mirroring the Qoder IDE model edit panel. + */ +export function createQoderModelPolicyProvider( + getModel: () => string, + settings: Record, +): (context: ModelPolicyContext) => ModelPolicyResult { + return (context) => { + const model = toQoderRuntimeModelId(getModel()); + try { + const override = getQoderSettings(settings).modelOverrides[model]; + if (!override) return { model }; + + const live = context.availableModels?.find(candidate => candidate.value === model); + const discovered = getQoderSettings(settings).discoveredModels + .find(candidate => candidate.value === model + || toQoderRuntimeModelId(candidate.value) === model); + + const parameters: Record = {}; + if (override.contextWindow !== undefined) { + const knownWindows = live?.context_config + ? Object.values(live.context_config).map(entry => entry.token_count) + : discovered?.contextTiers?.map(tier => tier.tokenCount); + if (!knownWindows || knownWindows.includes(override.contextWindow)) { + parameters.contextWindow = override.contextWindow; + } + } + const canDisableThinking = live + ? live.thinking_config?.disabled !== undefined + : discovered?.thinkingDisableable === true; + if (override.thinkingEnabled === false && canDisableThinking) { + parameters.reasoningEffort = 'none'; + } else if (override.thinkingEffort !== undefined) { + const knownEfforts = live?.thinking_config?.enabled?.efforts + ? Object.keys(live.thinking_config.enabled.efforts) + : discovered?.thinkingEfforts?.map(effort => effort.value); + if (!knownEfforts || knownEfforts.includes(override.thinkingEffort)) { + parameters.reasoningEffort = override.thinkingEffort; + } + } + + return Object.keys(parameters).length > 0 ? { model, parameters } : { model }; + } catch { + // The provider must never fail a turn — fall back to the bare model. + return { model }; + } + }; +} diff --git a/src/qoder/models/qoder-model-config.ts b/src/qoder/models/qoder-model-config.ts index 3415ce8..1c50f85 100644 --- a/src/qoder/models/qoder-model-config.ts +++ b/src/qoder/models/qoder-model-config.ts @@ -2,12 +2,14 @@ import type { ReasoningOption, UIOption, } from '../../core/types/services'; -import { updateQoderSettings } from '../config/settings'; +import type { ModelContextTier, ModelThinkingEffort } from '../../core/types/settings'; +import { getQoderSettings, updateQoderSettings } from '../config/settings'; import { DEFAULT_EFFORT_LEVEL, EFFORT_LEVELS, getContextWindowSize, normalizeEffortLevel, + sortThinkingEfforts, supportsXHighEffort, } from './model-catalog'; import { getQoderModelOptions } from './model-options'; @@ -19,6 +21,10 @@ export interface QoderModelConfig { getReasoningOptions(model: string): ReasoningOption[]; getDefaultReasoningValue(model: string): string; getContextWindowSize(model: string): number; + getModelContextTiers(model: string, settings: Record): ModelContextTier[]; + isThinkingDisableable(model: string, settings: Record): boolean; + getModelThinkingEfforts(model: string, settings: Record): ModelThinkingEffort[]; + getEffectiveContextWindowSize(model: string, settings: Record): number; applyModelDefaults(model: string, settings: unknown): void; normalizeModelVariant(model: string, settings: Record): string; } @@ -51,6 +57,47 @@ export const qoderModelConfig: QoderModelConfig = { return getContextWindowSize(toQoderRuntimeModelId(model)); }, + getModelContextTiers(model, settings): ModelContextTier[] { + const runtimeModel = toQoderRuntimeModelId(model); + const discovered = getQoderSettings(settings).discoveredModels + .find(candidate => candidate.value === runtimeModel + || toQoderRuntimeModelId(candidate.value) === runtimeModel); + // Older persisted data may predate tier sorting; normalize on read. + return [...(discovered?.contextTiers ?? [])] + .sort((a, b) => a.tokenCount - b.tokenCount); + }, + + isThinkingDisableable(model, settings): boolean { + const runtimeModel = toQoderRuntimeModelId(model); + const discovered = getQoderSettings(settings).discoveredModels + .find(candidate => candidate.value === runtimeModel + || toQoderRuntimeModelId(candidate.value) === runtimeModel); + return discovered?.thinkingDisableable === true; + }, + + getModelThinkingEfforts(model, settings): ModelThinkingEffort[] { + const runtimeModel = toQoderRuntimeModelId(model); + const discovered = getQoderSettings(settings).discoveredModels + .find(candidate => candidate.value === runtimeModel + || toQoderRuntimeModelId(candidate.value) === runtimeModel); + // Older persisted data may predate effort sorting; normalize on read. + return sortThinkingEfforts(discovered?.thinkingEfforts ?? []); + }, + + getEffectiveContextWindowSize(model, settings): number { + const runtimeModel = toQoderRuntimeModelId(model); + const tiers = qoderModelConfig.getModelContextTiers(runtimeModel, settings); + const override = getQoderSettings(settings).modelOverrides[runtimeModel]; + if (override?.contextWindow !== undefined + && tiers.some(tier => tier.tokenCount === override.contextWindow)) { + return override.contextWindow; + } + const defaultTier = tiers.find(tier => tier.isDefault); + if (defaultTier) return defaultTier.tokenCount; + if (tiers.length > 0) return tiers[0].tokenCount; + return getContextWindowSize(runtimeModel); + }, + applyModelDefaults(model: string, settings: unknown): void { const target = settings as Record; diff --git a/src/qoder/runtime/qoder-query-options-builder.ts b/src/qoder/runtime/qoder-query-options-builder.ts index f2d8284..5c5a6f5 100644 --- a/src/qoder/runtime/qoder-query-options-builder.ts +++ b/src/qoder/runtime/qoder-query-options-builder.ts @@ -15,6 +15,7 @@ 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 { buildSystemPrompt, @@ -151,6 +152,13 @@ export class QueryOptionsBuilder { QueryOptionsBuilder.applyThinking(options, ctx.settings, runtimeModel); options.hooks = ctx.hooks; + // Pull mode: the provider re-reads live settings so model/override + // changes apply on the next LLM call without restarting the query. + options.resolveModel = createQoderModelPolicyProvider( + () => ctx.settings.model, + ctx.settings, + ); + options.enableFileCheckpointing = true; if (ctx.resume) { @@ -198,6 +206,10 @@ export class QueryOptionsBuilder { ); options.hooks = ctx.hooks; QueryOptionsBuilder.applyThinking(options, ctx.settings, selectedModel); + options.resolveModel = createQoderModelPolicyProvider( + () => selectedModel, + ctx.settings, + ); if (ctx.allowedTools !== undefined && ctx.allowedTools.length > 0) { options.allowedTools = ctx.allowedTools; diff --git a/src/shared/icons.ts b/src/shared/icons.ts index ddded2d..998a7ca 100644 --- a/src/shared/icons.ts +++ b/src/shared/icons.ts @@ -65,6 +65,35 @@ export const QODER_ICON: IconSvg = { svg: qoderIconSvg, }; +/** Filled pencil glyph for the per-model edit affordance. */ +export const PENCIL_ICON: IconSvg = { + viewBox: '0 0 24 24', + path: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', +}; + +/** Filled back chevron for the model editor header. */ +export const CHEVRON_LEFT_ICON: IconSvg = { + viewBox: '0 0 24 24', + path: 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z', +}; + +/** Stroke-based checkmark for selected editor rows. */ +export const CHECK_ICON: IconSvg = { + kind: 'composite', + viewBox: '0 0 24 24', + children: [{ + tag: 'path', + attributes: { + d: 'M20 6L9 17l-5-5', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '3', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + }], +}; + export const QODERIAN_ICON_ID = 'qoderian-logo'; // Obsidian draws registered icons inside a `0 0 100 100` viewport, so the mark diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index f064ba4..99b9eb8 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -44,21 +44,78 @@ inset-inline-start: 0; margin-bottom: 0; display: flex; - flex-direction: column; - gap: 2px; - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); + flex-direction: row; + align-items: flex-start; + gap: 8px; z-index: 1000; width: max-content; - max-width: min(340px, calc(100% - 16px)); - padding: 4px; + max-width: calc(100vw - 16px); opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; } +/* Both panes are separate floating cards, mirroring the Qoder IDE. */ +.qoderian-model-list-pane { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + min-height: 0; + flex: 0 1 auto; + overflow-y: auto; + max-width: min(340px, calc(100vw - 24px)); + /* Measured by updateDropdownPlacement(); fallback matches the repo-wide + popover height convention. */ + max-height: var(--qoderian-model-dropdown-max-height, 420px); + padding: 4px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); +} + +/* Compact editor card: height fits its content and it flyouts beside the + row being edited instead of stretching to the list height. */ +.qoderian-model-editor-pane { + display: none; + flex-direction: column; + gap: 2px; + min-height: 0; + overflow-y: auto; + width: 260px; + max-width: calc(100vw - 24px); + margin-top: var(--qoderian-model-editor-offset, 0px); + padding: 4px 8px 8px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); +} + +.qoderian-model-dropdown--editing .qoderian-model-editor-pane { + display: flex; +} + +/* Flips only when start alignment would overflow the viewport; decided by + shouldFlipModelDropdown() against real measurements. */ +.qoderian-model-dropdown--flip { + inset-inline-start: auto; + inset-inline-end: 0; +} + +/* Very narrow windows: stack the editor card under the list card. */ +@media (max-width: 640px) { + .qoderian-model-dropdown--editing { + flex-direction: column; + } + + .qoderian-model-dropdown--editing .qoderian-model-editor-pane { + width: auto; + margin-top: 0; + } +} + .qoderian-model-runtime-status { display: flex; flex-direction: column; @@ -183,3 +240,183 @@ color: var(--qoderian-brand); font-size: 10px; } + +/* Per-model edit affordance: collapsed and invisible until the row is hovered + (or the affordance is focused via keyboard / the row is being edited); the + label expands on hover/edit like the IDE. */ +.qoderian-model-edit { + display: flex; + align-items: center; + gap: 4px; + margin-inline-start: 0; + padding-left: 0; + flex-shrink: 0; + width: 0; + overflow: hidden; + color: var(--text-muted); + border-radius: 3px; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s ease; +} + +.qoderian-model-option:hover .qoderian-model-edit, +.qoderian-model-option.editing .qoderian-model-edit, +.qoderian-model-edit:focus-visible { + width: auto; + overflow: visible; + opacity: 1; + pointer-events: auto; +} + +.qoderian-model-edit:hover, +.qoderian-model-edit:focus-visible { + color: var(--text-normal); + outline: none; +} + +.qoderian-model-edit-label { + display: none; + font-size: 11px; +} + +/* When the meta slot is hidden the edit affordance takes the trailing slot. */ +.qoderian-model-option:hover .qoderian-model-edit, +.qoderian-model-option.editing .qoderian-model-edit, +.qoderian-model-edit:focus-visible { + margin-inline-start: auto; + padding-left: 12px; +} + +.qoderian-model-option:hover .qoderian-model-edit-label, +.qoderian-model-option.editing .qoderian-model-edit-label, +.qoderian-model-edit:focus-visible .qoderian-model-edit-label { + display: inline; +} + +.qoderian-model-option:hover .qoderian-model-meta, +.qoderian-model-option.editing .qoderian-model-meta { + display: none; +} + +/* Per-model editor view (context window tiers + thinking toggle). */ +.qoderian-model-editor-head { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 6px; + margin-bottom: 4px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.qoderian-model-editor-back { + display: flex; + align-items: center; + border-radius: 3px; + cursor: pointer; + color: var(--text-muted); +} + +.qoderian-model-editor-back:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.qoderian-model-editor-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-weight: 600; + color: var(--text-normal); +} + +.qoderian-model-editor-section { + padding: 6px 8px 2px; + font-size: 11px; + color: var(--text-muted); +} + +/* Separates the Thinking Effort section from the toggle above, mirroring the + list pane's group divider so the option groups stay distinct. */ +.qoderian-model-editor-section--divided { + margin-top: 4px; + border-top: 1px solid var(--background-modifier-border); + padding-top: 6px; +} + +.qoderian-model-editor-tier { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + /* IDE shows every tier row in normal text; selection is marked by the check. */ + color: var(--text-normal); +} + +.qoderian-model-editor-tier:hover { + background: var(--background-modifier-hover); +} + +.qoderian-model-editor-tier.selected { + color: var(--text-normal); + font-weight: 500; +} + +.qoderian-model-editor-tier-default { + font-size: 10px; + color: var(--text-faint); +} + +.qoderian-model-editor-check { + margin-inline-start: auto; + color: var(--qoderian-brand); +} + +.qoderian-model-editor-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 4px; + padding: 8px; + border-top: 1px solid var(--background-modifier-border); + font-size: 12px; + color: var(--text-muted); +} + +/* IDE-style green switch. */ +.qoderian-model-editor-toggle { + position: relative; + width: 34px; + height: 20px; + border-radius: 10px; + background: var(--background-modifier-border-hover); + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s ease; +} + +.qoderian-model-editor-toggle::after { + content: ''; + position: absolute; + top: 2px; + inset-inline-start: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text-normal); + transition: inset-inline-start 0.15s ease; +} + +.qoderian-model-editor-toggle.is-on { + background: var(--qoderian-brand); +} + +.qoderian-model-editor-toggle.is-on::after { + inset-inline-start: 16px; + background: #fff; +} diff --git a/tests/helpers/mock-element.ts b/tests/helpers/mock-element.ts index b55102f..4d6dc5d 100644 --- a/tests/helpers/mock-element.ts +++ b/tests/helpers/mock-element.ts @@ -287,6 +287,8 @@ export function createMockEl(tag = 'div'): any { children.length = 0; element.innerHTML = ''; textContent = ''; + // Real DOM clamps scroll once the scrolled content is removed. + element.scrollTop = 0; }, contains(node: any) { if (node === element) return true; 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 3120399..941430e 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 @@ -170,6 +170,377 @@ describe('ModelSelector', () => { expect(parentEl.querySelector('.qoderian-model-label')?.textContent).toBe('Auto'); expect(parentEl.querySelector('.qoderian-model-runtime-title')?.textContent).toBe('Loading models…'); }); + + describe('per-model editor', () => { + const editableModel = { + value: 'qmodel', + label: 'Qwen 3.8 Max', + group: 'New models', + contextTiers: [ + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: '400K', tokenCount: 400000, isDefault: false }, + { label: '1M', tokenCount: 1000000, isDefault: false }, + ], + thinkingDisableable: true, + }; + + function buildCallbacks( + extra: Record = {}, + overrides: Record> = {}, + efforts: Array> = [], + ) { + 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(''), + getModelConfig: jest.fn().mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([ + { value: 'auto', label: 'Auto', group: 'Qoder' }, + editableModel, + ]), + getModelContextTiers: jest.fn().mockReturnValue(editableModel.contextTiers), + isThinkingDisableable: jest.fn().mockReturnValue(true), + getModelThinkingEfforts: jest.fn().mockReturnValue(efforts), + // Stateful: reflects whatever override the editor just persisted. + getEffectiveContextWindowSize: jest.fn(() => + overrides.qmodel?.contextWindow ?? 200000), + }), + ...extra, + }; + } + + it('offers editing only for models with editable metadata', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + })); + + const edits = parentEl.querySelectorAll('.qoderian-model-edit'); + expect(edits).toHaveLength(1); + }); + + it('hides the edit affordance when the callback is missing', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks()); + + expect(parentEl.querySelectorAll('.qoderian-model-edit')).toHaveLength(0); + }); + + it('opens the editor view with tiers, default tag, and thinking toggle', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + })); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + + expect(parentEl.querySelector('.qoderian-model-editor-title')?.textContent) + .toBe('Qwen 3.8 Max'); + const tiers = parentEl.querySelectorAll('.qoderian-model-editor-tier'); + expect(tiers).toHaveLength(3); + expect(tiers[0].hasClass('selected')).toBe(true); + expect(tiers[0].getAttribute('aria-selected')).toBe('true'); + expect(tiers[1].hasClass('selected')).toBe(false); + expect(parentEl.querySelector('.qoderian-model-editor-tier-default')?.textContent) + .toBe('Default'); + const toggle = parentEl.querySelector('.qoderian-model-editor-toggle'); + expect(toggle?.hasClass('is-on')).toBe(true); + expect(toggle?.getAttribute('aria-checked')).toBe('true'); + }); + + it('persists a tier choice and clears the override for the default tier', async () => { + const parentEl = createMockEl(); + const overrides: Record> = { + qmodel: { contextWindow: 400000 }, + }; + const onModelOverrideChange = jest.fn(async (_model: string, patch: Record) => { + // Mirror tab.ts: undefined values delete the key, empty overrides vanish. + 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)); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + // Start from a 400K override: choose 1M, then back to the default tier. + parentEl.querySelectorAll('.qoderian-model-editor-tier')[2]?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { contextWindow: 1000000 }); + + parentEl.querySelectorAll('.qoderian-model-editor-tier')[0]?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { contextWindow: undefined }); + }); + + it('toggles thinking mode off and back on', async () => { + const parentEl = createMockEl(); + const overrides: Record> = {}; + const onModelOverrideChange = jest.fn(async (_model: string, patch: Record) => { + overrides.qmodel = { ...overrides.qmodel, ...patch }; + }); + new ModelSelector(parentEl, buildCallbacks({ onModelOverrideChange }, overrides)); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + parentEl.querySelector('.qoderian-model-editor-toggle')?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { thinkingEnabled: false }); + + const toggle = parentEl.querySelector('.qoderian-model-editor-toggle'); + expect(toggle?.hasClass('is-on')).toBe(false); + toggle?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { thinkingEnabled: true }); + }); + + it('shows thinking as disabled when the override turns it off', () => { + const parentEl = createMockEl(); + const callbacks = buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + getSettings: jest.fn().mockReturnValue({ + model: 'qmodel', + effortLevel: 'high', + permissionMode: 'acceptEdits', + qoder: { modelOverrides: { qmodel: { thinkingEnabled: false } } }, + }), + }); + new ModelSelector(parentEl, callbacks); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + + const toggle = parentEl.querySelector('.qoderian-model-editor-toggle'); + expect(toggle?.hasClass('is-on')).toBe(false); + expect(toggle?.getAttribute('aria-checked')).toBe('false'); + }); + + it('returns to the model list via the back button', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + })); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeTruthy(); + + parentEl.querySelector('.qoderian-model-editor-back')?.click(); + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeNull(); + expect(parentEl.querySelectorAll('.qoderian-model-option').length).toBeGreaterThan(0); + }); + + it('keeps the model list scroll position across editor open and close', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + })); + + const listPane = parentEl.querySelector('.qoderian-model-list-pane'); + if (listPane) listPane.scrollTop = 42; + + parentEl.querySelector('.qoderian-model-edit')?.click(); + expect(parentEl.querySelector('.qoderian-model-list-pane')?.scrollTop).toBe(42); + + parentEl.querySelector('.qoderian-model-editor-back')?.click(); + expect(parentEl.querySelector('.qoderian-model-list-pane')?.scrollTop).toBe(42); + }); + + it('returns to the model list when the dropdown reopens after editing', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + })); + + const trigger = parentEl.querySelector('.qoderian-model-btn'); + trigger?.click(); + parentEl.querySelector('.qoderian-model-edit')?.click(); + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeTruthy(); + + // Close, then reopen: the editor view must reset to the model list. + trigger?.click(); + trigger?.click(); + + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeNull(); + expect(parentEl.querySelector('.qoderian-model-dropdown') + ?.hasClass('qoderian-model-dropdown--editing')).toBe(false); + }); + + it('also resets the editor view when reopened via keyboard', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + })); + + const trigger = parentEl.querySelector('.qoderian-model-btn'); + const pressEnter = () => trigger?.dispatchEvent({ + type: 'keydown', + key: 'Enter', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + pressEnter(); + parentEl.querySelector('.qoderian-model-edit')?.click(); + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeTruthy(); + + pressEnter(); // close + pressEnter(); // reopen -> back to the model list + + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeNull(); + }); + + it('drops a stale editor card when the runtime degrades mid-edit', () => { + const parentEl = createMockEl(); + const notify: { current: (() => void) | null } = { current: null }; + const getRuntimeStatus = jest.fn().mockReturnValue({ kind: 'ready', message: '' }); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + getRuntimeStatus, + subscribeRuntimeStatus: jest.fn((listener: () => void) => { + notify.current = listener; + return () => {}; + }), + })); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeTruthy(); + + // Runtime drops while the editor is open: the card must not linger. + getRuntimeStatus.mockReturnValue({ kind: 'authRequired', message: 'Sign in required' }); + notify.current?.(); + + expect(parentEl.querySelector('.qoderian-model-editor-head')).toBeNull(); + expect(parentEl.querySelector('.qoderian-model-dropdown') + ?.hasClass('qoderian-model-dropdown--editing')).toBe(false); + }); + + describe('thinking effort levels', () => { + const thinkingEfforts = [ + { value: 'low', isDefault: false, description: 'Minimal reasoning' }, + { value: 'medium', isDefault: true }, + { value: 'xhigh', isDefault: false }, + ]; + + it('renders server effort rows under the thinking toggle', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + }, {}, thinkingEfforts)); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + + // The intensity list gets its own labeled section like the IDE. + expect(parentEl.querySelector('.qoderian-model-editor-section--divided')?.textContent) + .toBe('Thinking Effort'); + // 3 context tiers + 3 effort rows share the tier row style. + const rows = parentEl.querySelectorAll('.qoderian-model-editor-tier'); + expect(rows).toHaveLength(6); + expect(rows.slice(3).map((row: ReturnType) => + 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. + expect(rows[4].hasClass('selected')).toBe(true); + expect(rows[4].getAttribute('aria-selected')).toBe('true'); + expect(rows[3].hasClass('selected')).toBe(false); + expect(rows[4].querySelector('.qoderian-model-editor-tier-default')?.textContent) + .toBe('Default'); + }); + + it('hides effort rows when thinking is turned off', () => { + const parentEl = createMockEl(); + new ModelSelector(parentEl, buildCallbacks({ + onModelOverrideChange: jest.fn().mockResolvedValue(undefined), + }, { qmodel: { thinkingEnabled: false } }, thinkingEfforts)); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + + // Only the 3 context tiers remain. + expect(parentEl.querySelectorAll('.qoderian-model-editor-tier')).toHaveLength(3); + }); + + it('persists an effort choice and clears the override for the default', async () => { + 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, thinkingEfforts)); + + parentEl.querySelector('.qoderian-model-edit')?.click(); + // Choose xhigh (tier index 3 + effort index 2). + parentEl.querySelectorAll('.qoderian-model-editor-tier')[5]?.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(onModelOverrideChange).toHaveBeenCalledWith('qmodel', { thinkingEffort: 'xhigh' }); + expect(parentEl.querySelectorAll('.qoderian-model-editor-tier')[5]?.hasClass('selected')) + .toBe(true); + + // Choosing the server default clears the override entirely. + parentEl.querySelectorAll('.qoderian-model-editor-tier')[4]?.click(); + await Promise.resolve(); + await Promise.resolve(); + 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', () => { diff --git a/tests/unit/features/chat/ui/model-dropdown-placement.test.ts b/tests/unit/features/chat/ui/model-dropdown-placement.test.ts new file mode 100644 index 0000000..ba76154 --- /dev/null +++ b/tests/unit/features/chat/ui/model-dropdown-placement.test.ts @@ -0,0 +1,77 @@ +import { + MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT, + MODEL_DROPDOWN_MIN_HEIGHT, + MODEL_DROPDOWN_VIEWPORT_MARGIN, + modelDropdownMaxHeight, + modelEditorPaneOffset, + shouldFlipModelDropdown, +} from '@/features/chat/ui/toolbar/model-dropdown-placement'; + +describe('shouldFlipModelDropdown', () => { + it('keeps start alignment when the panel fits at the start edge', () => { + expect(shouldFlipModelDropdown({ left: 20, right: 140, top: 800 }, 300, 1200)).toBe(false); + }); + + it('prefers start alignment when the panel fits on both edges', () => { + expect(shouldFlipModelDropdown({ left: 400, right: 520, top: 800 }, 300, 1200)).toBe(false); + }); + + it('flips only when the start edge overflows and the end edge fits', () => { + // Right-hand sidebar: a 960px cascade panel cannot grow rightward. + expect(shouldFlipModelDropdown({ left: 1000, right: 1120, top: 800 }, 960, 1200)).toBe(true); + }); + + it('stays at start when neither edge fits (narrow window stacking takes over)', () => { + expect(shouldFlipModelDropdown({ left: 1000, right: 1120, top: 800 }, 1190, 1200)).toBe(false); + }); + + it('treats an exact fit at the start edge as fitting', () => { + const anchor = { left: 200, right: 320, top: 800 }; + const width = 1200 - MODEL_DROPDOWN_VIEWPORT_MARGIN - anchor.left; + expect(shouldFlipModelDropdown(anchor, width, 1200)).toBe(false); + }); + + it('treats an exact fit at the end edge as fitting', () => { + const anchor = { left: 1000, right: 1120, top: 800 }; + const width = anchor.right - MODEL_DROPDOWN_VIEWPORT_MARGIN; + expect(shouldFlipModelDropdown(anchor, width, 1200)).toBe(true); + }); +}); + +describe('modelDropdownMaxHeight', () => { + it('caps tall windows at the repo-wide popover height convention', () => { + expect(modelDropdownMaxHeight(1000)).toBe(MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT); + }); + + it('shrinks to the space above the toolbar in short windows', () => { + expect(modelDropdownMaxHeight(300)).toBe(300 - MODEL_DROPDOWN_VIEWPORT_MARGIN); + }); + + it('floors the height so the panel stays usable in tiny windows', () => { + expect(modelDropdownMaxHeight(60)).toBe(MODEL_DROPDOWN_MIN_HEIGHT); + }); + + it('falls back to the convention when the measurement is not finite', () => { + expect(modelDropdownMaxHeight(Number.NaN)).toBe(MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT); + }); +}); + +describe('modelEditorPaneOffset', () => { + it('anchors the editor card to the visible top of the edited row', () => { + expect(modelEditorPaneOffset(80, 220, 420)).toBe(80); + }); + + it('clamps when the editor is taller than the space below the row', () => { + expect(modelEditorPaneOffset(300, 220, 420)).toBe(200); + }); + + it('clamps to zero for rows at or above the visible list top', () => { + expect(modelEditorPaneOffset(-10, 220, 420)).toBe(0); + }); + + it('returns zero when any measurement is not finite', () => { + expect(modelEditorPaneOffset(Number.NaN, 220, 420)).toBe(0); + expect(modelEditorPaneOffset(80, Number.NaN, 420)).toBe(0); + expect(modelEditorPaneOffset(80, 220, Number.NaN)).toBe(0); + }); +}); diff --git a/tests/unit/qoder/commands/probe-runtime-commands.test.ts b/tests/unit/qoder/commands/probe-runtime-commands.test.ts index 8962b10..4a15e1b 100644 --- a/tests/unit/qoder/commands/probe-runtime-commands.test.ts +++ b/tests/unit/qoder/commands/probe-runtime-commands.test.ts @@ -246,6 +246,65 @@ describe('probeRuntimeCatalog', () => { }); }); + it('maps context_config and thinking_config for the per-model editor', async () => { + setInitMessage(); + sdkMock.setMockAvailableModels([ + { + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + context_config: { + '1M': { token_count: 1000000, is_default: false }, + '200K': { token_count: 200000, is_default: true }, + ' 400K ': { token_count: 400000, is_default: false }, + 'bogus': { token_count: -1 }, + }, + thinking_config: { + disabled: { description: 'Respond directly' }, + enabled: { + description: 'Think first', + efforts: { xhigh: { is_default: true }, low: { description: 'Minimal' } }, + is_default: true, + }, + }, + }, + { + value: 'no-thinking-disable', + displayName: 'Thinking only', + description: '', + thinking_config: { + enabled: { description: 'Think first', efforts: { high: {} }, is_default: true }, + }, + }, + ]); + + const result = await probeRuntimeCatalog(createMockPlugin()); + expectSuccessfulProbe(result); + + expect(result.models[0]).toEqual({ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + group: 'Qoder', + contextTiers: [ + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: '400K', tokenCount: 400000, isDefault: false }, + { label: '1M', tokenCount: 1000000, isDefault: false }, + ], + thinkingDisableable: true, + thinkingEfforts: [ + { value: 'low', isDefault: false, description: 'Minimal' }, + { value: 'xhigh', isDefault: true }, + ], + }); + // Without thinking_config.disabled the toggle must stay hidden. + expect(result.models[1]).not.toHaveProperty('thinkingDisableable'); + expect(result.models[1]).not.toHaveProperty('contextTiers'); + expect(result.models[1]).toHaveProperty('thinkingEfforts', [ + { value: 'high', isDefault: false }, + ]); + }); + it('uses initialization pricing when a live catalog refresh returns empty', async () => { setInitMessage(); sdkMock.setMockAvailableModels([]); diff --git a/tests/unit/qoder/config/model-overrides.test.ts b/tests/unit/qoder/config/model-overrides.test.ts new file mode 100644 index 0000000..f25c577 --- /dev/null +++ b/tests/unit/qoder/config/model-overrides.test.ts @@ -0,0 +1,133 @@ +import { getQoderModelOverride, getQoderSettings } from '@/qoder/config/settings'; + +describe('qoder settings model editor fields', () => { + describe('modelOverrides normalization', () => { + it('defaults to an empty map', () => { + expect(getQoderSettings({}).modelOverrides).toEqual({}); + }); + + it('keeps valid contextWindow and thinkingEnabled entries', () => { + const settings = { + qoder: { + modelOverrides: { + qmodel_38max: { contextWindow: 400000, thinkingEnabled: false }, + }, + }, + }; + + expect(getQoderSettings(settings).modelOverrides).toEqual({ + qmodel_38max: { contextWindow: 400000, thinkingEnabled: false }, + }); + }); + + it('drops non-positive or non-numeric contextWindow values', () => { + const settings = { + qoder: { + modelOverrides: { + a: { contextWindow: 0 }, + b: { contextWindow: -5 }, + c: { contextWindow: 'wide' }, + }, + }, + }; + + expect(getQoderSettings(settings).modelOverrides).toEqual({}); + }); + + it('drops entries that normalize to nothing', () => { + const settings = { + qoder: { + modelOverrides: { + a: {}, + b: 'garbage', + c: null, + d: { contextWindow: 1000000 }, + }, + }, + }; + + expect(getQoderSettings(settings).modelOverrides).toEqual({ + d: { contextWindow: 1000000 }, + }); + }); + + it('ignores non-object modelOverrides roots', () => { + expect(getQoderSettings({ qoder: { modelOverrides: ['x'] } }).modelOverrides).toEqual({}); + expect(getQoderSettings({ qoder: { modelOverrides: 'x' } }).modelOverrides).toEqual({}); + }); + }); + + describe('discoveredModels context/thinking metadata', () => { + it('normalizes contextTiers and thinkingDisableable', () => { + const settings = { + qoder: { + discoveredModels: [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [ + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: ' 1M ', tokenCount: 1000000 }, + ], + thinkingDisableable: true, + }], + }, + }; + + const model = getQoderSettings(settings).discoveredModels[0]; + expect(model.contextTiers).toEqual([ + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: '1M', tokenCount: 1000000, isDefault: false }, + ]); + expect(model.thinkingDisableable).toBe(true); + }); + + it('drops invalid tier entries instead of failing', () => { + const settings = { + qoder: { + discoveredModels: [{ + value: 'm', + displayName: 'M', + description: '', + contextTiers: [ + 'garbage', + { label: ' ', tokenCount: 100 }, + { label: 'Zero', tokenCount: 0 }, + { label: 'OK', tokenCount: 100 }, + ], + }], + }, + }; + + const model = getQoderSettings(settings).discoveredModels[0]; + expect(model.contextTiers).toEqual([{ label: 'OK', tokenCount: 100, isDefault: false }]); + }); + + it('omits contextTiers and thinkingDisableable when absent', () => { + const settings = { + qoder: { + discoveredModels: [{ value: 'm', displayName: 'M', description: '' }], + }, + }; + + const model = getQoderSettings(settings).discoveredModels[0]; + expect(model.contextTiers).toBeUndefined(); + expect(model.thinkingDisableable).toBeUndefined(); + }); + }); + + describe('getQoderModelOverride', () => { + it('returns the override for a customized model', () => { + const settings = { + qoder: { modelOverrides: { auto: { thinkingEnabled: false } } }, + }; + + expect(getQoderModelOverride(settings, 'auto')).toEqual({ thinkingEnabled: false }); + }); + + it('returns undefined for untouched models and malformed settings', () => { + expect(getQoderModelOverride({}, 'auto')).toBeUndefined(); + expect(getQoderModelOverride({ qoder: 42 }, 'auto')).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/qoder/models/model-policy.test.ts b/tests/unit/qoder/models/model-policy.test.ts new file mode 100644 index 0000000..b7aa487 --- /dev/null +++ b/tests/unit/qoder/models/model-policy.test.ts @@ -0,0 +1,313 @@ +import type { ModelPolicyContext } from '@qoder-ai/qoder-agent-sdk'; + +import { createQoderModelPolicyProvider } from '@/qoder/models/model-policy'; + +function createContext(overrides: Partial = {}): ModelPolicyContext { + return { + purpose: 'main', + sessionId: 'session-1', + turnIndex: 0, + availableModels: [], + ...overrides, + } as ModelPolicyContext; +} + +function settingsWith( + modelOverrides: Record, + discoveredModels: Array> = [], +): Record { + return { + qoder: { modelOverrides, discoveredModels }, + }; +} + +describe('createQoderModelPolicyProvider', () => { + it('returns the bare model when no override exists', () => { + const provider = createQoderModelPolicyProvider(() => 'auto', settingsWith({})); + + expect(provider(createContext())).toEqual({ model: 'auto' }); + }); + + it('normalizes the selected model id like the rest of the runtime', () => { + const provider = createQoderModelPolicyProvider(() => ' Auto ', settingsWith({})); + + expect(provider(createContext()).model).toBe('auto'); + }); + + it('keeps custom model ids verbatim', () => { + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settingsWith({})); + + expect(provider(createContext()).model).toBe('qmodel_38max'); + }); + + describe('contextWindow override', () => { + it('emits contextWindow when it matches a discovered tier', () => { + const settings = settingsWith( + { qmodel_38max: { contextWindow: 400000 } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [ + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: '400K', tokenCount: 400000, isDefault: false }, + ], + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { contextWindow: 400000 }, + }); + }); + + it('drops contextWindow when it matches no known tier', () => { + const settings = settingsWith( + { qmodel_38max: { contextWindow: 123456 } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [{ label: '200K', tokenCount: 200000, isDefault: true }], + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ model: 'qmodel_38max' }); + }); + + it('passes contextWindow through when no catalog data is available', () => { + const settings = settingsWith({ qmodel_38max: { contextWindow: 1000000 } }); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { contextWindow: 1000000 }, + }); + }); + + it('prefers the live catalog over persisted tiers', () => { + const settings = settingsWith( + { qmodel_38max: { contextWindow: 400000 } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [{ label: '400K', tokenCount: 400000, isDefault: false }], + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + const liveOnly200K = createContext({ + availableModels: [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + context_config: { + '200K': { token_count: 200000, is_default: true }, + }, + }] as ModelPolicyContext['availableModels'], + }); + + // The live catalog no longer offers 400K, so the override is withheld. + expect(provider(liveOnly200K)).toEqual({ model: 'qmodel_38max' }); + }); + }); + + describe('thinking override', () => { + it('emits reasoningEffort none when the model supports disabling thinking', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEnabled: false } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinkingDisableable: true, + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { reasoningEffort: 'none' }, + }); + }); + + it('withholds reasoningEffort none when disabling is unsupported', () => { + const settings = settingsWith( + { 'custom-model': { thinkingEnabled: false } }, + [{ value: 'custom-model', displayName: 'Custom', description: '' }], + ); + const provider = createQoderModelPolicyProvider(() => 'custom-model', settings); + + expect(provider(createContext())).toEqual({ model: 'custom-model' }); + }); + + it('honors the live thinking_config over the persisted flag', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEnabled: false } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinkingDisableable: true, + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + const live = createContext({ + availableModels: [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinking_config: { enabled: { efforts: {}, is_default: true } }, + }] as ModelPolicyContext['availableModels'], + }); + + // Live catalog has no thinking_config.disabled → refusing is the safe path. + expect(provider(live)).toEqual({ model: 'qmodel_38max' }); + }); + + it('does not emit reasoningEffort when thinking stays enabled', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEnabled: true } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinkingDisableable: true, + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ model: 'qmodel_38max' }); + }); + }); + + describe('thinking effort override', () => { + const discoveredWithEfforts = [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinkingEfforts: [ + { value: 'low', isDefault: false }, + { value: 'medium', isDefault: true }, + { value: 'xhigh', isDefault: false }, + ], + }]; + + it('emits reasoningEffort when it matches a discovered effort', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEffort: 'xhigh' } }, + discoveredWithEfforts, + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { reasoningEffort: 'xhigh' }, + }); + }); + + it('drops reasoningEffort when it matches no known effort', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEffort: 'ultra' } }, + discoveredWithEfforts, + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ model: 'qmodel_38max' }); + }); + + it('passes reasoningEffort through when no catalog data is available', () => { + const settings = settingsWith({ 'custom-model': { thinkingEffort: 'high' } }); + const provider = createQoderModelPolicyProvider(() => 'custom-model', settings); + + expect(provider(createContext())).toEqual({ + model: 'custom-model', + parameters: { reasoningEffort: 'high' }, + }); + }); + + it('prefers the live thinking_config efforts over the persisted ones', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEffort: 'xhigh' } }, + discoveredWithEfforts, + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + const live = createContext({ + availableModels: [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinking_config: { + enabled: { efforts: { low: {}, medium: { is_default: true } } }, + }, + }] as ModelPolicyContext['availableModels'], + }); + + // The live catalog no longer offers xhigh, so the override is withheld. + expect(provider(live)).toEqual({ model: 'qmodel_38max' }); + }); + + it('prefers disabling thinking over a stored effort', () => { + const settings = settingsWith( + { qmodel_38max: { thinkingEnabled: false, thinkingEffort: 'low' } }, + [{ ...discoveredWithEfforts[0], thinkingDisableable: true }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { reasoningEffort: 'none' }, + }); + }); + }); + + it('combines both parameters when both overrides apply', () => { + const settings = settingsWith( + { qmodel_38max: { contextWindow: 1000000, thinkingEnabled: false } }, + [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [{ label: '1M', tokenCount: 1000000, isDefault: false }], + thinkingDisableable: true, + }], + ); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { contextWindow: 1000000, reasoningEffort: 'none' }, + }); + }); + + it('reads overrides live so settings edits apply to the next call', () => { + const settings = settingsWith({}, [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinkingDisableable: true, + }]); + const provider = createQoderModelPolicyProvider(() => 'qmodel_38max', settings); + + expect(provider(createContext())).toEqual({ model: 'qmodel_38max' }); + + (settings.qoder as Record).modelOverrides = { + qmodel_38max: { thinkingEnabled: false }, + }; + expect(provider(createContext())).toEqual({ + model: 'qmodel_38max', + parameters: { reasoningEffort: 'none' }, + }); + }); + + it('never throws on malformed settings', () => { + const provider = createQoderModelPolicyProvider(() => 'auto', { + qoder: { modelOverrides: 'garbage', discoveredModels: { not: 'an array' } }, + }); + + expect(() => provider(createContext())).not.toThrow(); + expect(provider(createContext())).toEqual({ model: 'auto' }); + }); +}); diff --git a/tests/unit/qoder/models/qoder-model-config.test.ts b/tests/unit/qoder/models/qoder-model-config.test.ts index b34d983..64ecd98 100644 --- a/tests/unit/qoder/models/qoder-model-config.test.ts +++ b/tests/unit/qoder/models/qoder-model-config.test.ts @@ -49,4 +49,106 @@ describe('qoderModelConfig', () => { expect(settings.qoder).toEqual(expect.objectContaining({ lastModel: 'qoder-sonnet-4-5' })); }); }); + + describe('model editor metadata', () => { + const settings: Record = { + qoder: { + discoveredModels: [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [ + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: '400K', tokenCount: 400000, isDefault: false }, + { label: '1M', tokenCount: 1000000, isDefault: false }, + ], + thinkingDisableable: true, + }], + }, + }; + + it('exposes context tiers for discovered models', () => { + expect(qoderModelConfig.getModelContextTiers('qmodel_38max', settings)).toHaveLength(3); + expect(qoderModelConfig.getModelContextTiers('unknown-model', settings)).toEqual([]); + }); + + it('sorts persisted tiers ascending so stale unsorted data renders in order', () => { + const unsorted: Record = { + qoder: { + discoveredModels: [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + contextTiers: [ + { label: '1M', tokenCount: 1000000, isDefault: false }, + { label: '200K', tokenCount: 200000, isDefault: true }, + { label: '400K', tokenCount: 400000, isDefault: false }, + ], + }], + }, + }; + + expect(qoderModelConfig.getModelContextTiers('qmodel_38max', unsorted) + .map(tier => tier.label)).toEqual(['200K', '400K', '1M']); + }); + + it('sorts persisted efforts by intensity so stale unsorted data renders in order', () => { + const unsorted: Record = { + qoder: { + discoveredModels: [{ + value: 'cantus', + displayName: 'Cantus', + description: '', + thinkingEfforts: [ + { value: 'xhigh', isDefault: false }, + { value: 'max', isDefault: false }, + { value: 'low', isDefault: false }, + { value: 'high', isDefault: true }, + { value: 'medium', isDefault: false }, + ], + }], + }, + }; + + expect(qoderModelConfig.getModelThinkingEfforts('cantus', unsorted) + .map(effort => effort.value)).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); + }); + + it('reports thinking disable support from the discovery metadata', () => { + expect(qoderModelConfig.isThinkingDisableable('qmodel_38max', settings)).toBe(true); + expect(qoderModelConfig.isThinkingDisableable('unknown-model', settings)).toBe(false); + }); + + it('uses the server default tier when no override exists', () => { + expect(qoderModelConfig.getEffectiveContextWindowSize('qmodel_38max', settings)).toBe(200000); + }); + + it('honors a valid contextWindow override', () => { + const overridden: Record = { + qoder: { + ...settings.qoder as Record, + modelOverrides: { qmodel_38max: { contextWindow: 1000000 } }, + }, + }; + + expect(qoderModelConfig.getEffectiveContextWindowSize('qmodel_38max', overridden)) + .toBe(1000000); + }); + + it('falls back to the default tier when the override matches no tier', () => { + const stale: Record = { + qoder: { + ...settings.qoder as Record, + modelOverrides: { qmodel_38max: { contextWindow: 123456 } }, + }, + }; + + expect(qoderModelConfig.getEffectiveContextWindowSize('qmodel_38max', stale)).toBe(200000); + }); + + it('falls back to the static catalog window for models without tiers', () => { + expect(qoderModelConfig.getEffectiveContextWindowSize('auto', settings)) + .toBe(qoderModelConfig.getContextWindowSize('auto')); + }); + }); }); 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 11f275e..6e17a47 100644 --- a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts +++ b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts @@ -1,8 +1,11 @@ +import type { ModelPolicyContext } from '@qoder-ai/qoder-agent-sdk'; + import type { AppPluginManager } from '@/core/types/services'; import type { PermissionMode, QoderianSettings } from '@/core/types/settings'; import { DEFAULT_QODER_SETTINGS } from '@/qoder/config/settings'; import type { McpServerManager } from '@/qoder/mcp/mcp-server-manager'; import { + type ColdStartQueryContext, type PersistentQueryContext, QueryOptionsBuilder, } from '@/qoder/runtime/qoder-query-options-builder'; @@ -38,6 +41,25 @@ function createContext( }; } +function createColdStartContext(): ColdStartQueryContext { + return { + ...createContext('default'), + hasEditorContext: false, + mcpManager: { + getAllDisallowedMcpTools: jest.fn().mockReturnValue([]), + getActiveServers: jest.fn().mockReturnValue({}), + getDisallowedMcpTools: jest.fn().mockReturnValue([]), + } as unknown as McpServerManager, + }; +} + +const policyContext = { + purpose: 'main', + sessionId: 'session-1', + turnIndex: 0, + availableModels: [], +} as ModelPolicyContext; + describe('QueryOptionsBuilder permissions', () => { it.each(['default', 'acceptEdits', 'auto', 'plan'] as const)( 'maps %s directly without enabling dangerous permission bypass', @@ -89,3 +111,49 @@ describe('QueryOptionsBuilder permissions', () => { expect(QueryOptionsBuilder.needsRestart(yoloConfig, safeConfig)).toBe(true); }); }); + +describe('QueryOptionsBuilder model policy', () => { + it('registers a pull-mode resolveModel provider on persistent queries', () => { + const options = QueryOptionsBuilder.buildPersistentQueryOptions(createContext('default')); + + expect(typeof options.resolveModel).toBe('function'); + expect(options.resolveModel?.(policyContext)).toEqual({ model: 'auto' }); + }); + + it('re-reads live settings so editor overrides apply without a restart', () => { + const ctx = createContext('default'); + const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); + + // The user switches model and disables thinking mid-session. + (ctx.settings as { model: string }).model = 'qmodel_38max'; + ctx.settings.qoder.discoveredModels = [{ + value: 'qmodel_38max', + displayName: 'Qwen 3.8 Max', + description: '', + thinkingDisableable: true, + }]; + ctx.settings.qoder.modelOverrides = { + qmodel_38max: { thinkingEnabled: false }, + }; + + expect(options.resolveModel?.(policyContext)).toEqual({ + model: 'qmodel_38max', + parameters: { reasoningEffort: 'none' }, + }); + }); + + it('registers the provider on cold-start queries with the selected model', () => { + const ctx = createColdStartContext(); + ctx.modelOverride = 'qmodel_38max'; + ctx.settings.qoder.modelOverrides = { + qmodel_38max: { contextWindow: 1000000 }, + }; + + const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); + + expect(options.resolveModel?.(policyContext)).toEqual({ + model: 'qmodel_38max', + parameters: { contextWindow: 1000000 }, + }); + }); +});