diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9f11c5511..1be95d1d1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -65,7 +65,7 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.ts"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; import { @@ -252,6 +252,12 @@ export interface PromptOptions { preflightResult?: (success: boolean) => void; } +/** Options for model/thinking mutations. */ +export interface ModelMutationOptions { + /** Persist the new value to global defaults. Defaults to session-only. */ + persist?: boolean; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -297,9 +303,6 @@ function estimateMessagesTokens(messages: AgentMessage[]): number { // Constants // ============================================================================ -/** Standard thinking levels */ -const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; - // ============================================================================ // AgentSession Class // ============================================================================ @@ -1588,21 +1591,26 @@ export class AgentSession { /** * Set model directly. - * Validates that auth is configured, saves to session and settings. + * Validates that auth is configured and saves to the session transcript. + * Persists to global defaults only when options.persist is true. * @throws Error if no auth is configured for the model */ - async setModel(model: Model): Promise { + async setModel(model: Model, options: ModelMutationOptions = {}): Promise { if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(model); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + } - // Re-clamp thinking level for new model's capabilities + // Re-clamp session thinking level for new model's capabilities. + // Per-model thinking level overrides take priority over session carry-over. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(model, previousModel, "set"); @@ -1614,14 +1622,20 @@ export class AgentSession { * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ - async cycleModel(direction: "forward" | "backward" = "forward"): Promise { + async cycleModel( + direction: "forward" | "backward" = "forward", + options: ModelMutationOptions = {}, + ): Promise { if (this._scopedModels.length > 0) { - return this._cycleScopedModel(direction); + return this._cycleScopedModel(direction, options); } - return this._cycleAvailableModel(direction); + return this._cycleAvailableModel(direction, options); } - private async _cycleScopedModel(direction: "forward" | "backward"): Promise { + private async _cycleScopedModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableIds = new Set( this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`), ); @@ -1637,17 +1651,20 @@ export class AgentSession { const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = scopedModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); + const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel); // Apply model this.agent.state.model = next.model; this.sessionManager.appendModelChange(next.model.provider, next.model.id); - this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + } - // Apply thinking level. + // Apply session thinking level. // - Explicit scoped model thinking level overrides current session level // - Undefined scoped model thinking level inherits the current session preference // setThinkingLevel clamps to model capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(next.model, currentModel, "cycle"); @@ -1655,7 +1672,10 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { + private async _cycleAvailableModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableModels = this._modelRuntime.getAvailableSnapshot(); if (availableModels.length <= 1) return undefined; @@ -1667,12 +1687,15 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel); this.agent.state.model = nextModel; this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); - this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + } - // Re-clamp thinking level for new model's capabilities + // Re-clamp session thinking level for new model's capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(nextModel, currentModel, "cycle"); @@ -1687,9 +1710,10 @@ export class AgentSession { /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. - * Saves to session and settings only if the level actually changes. + * Saves the clamped level to the session transcript only if the level actually changes. + * Persists the requested level to global defaults only when options.persist is true. */ - setThinkingLevel(level: ThinkingLevel): void { + setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); @@ -1699,11 +1723,12 @@ export class AgentSession { this.agent.state.thinkingLevel = effectiveLevel; + if (options.persist) { + this.settingsManager.setDefaultThinkingLevel(level); + } + if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); - if (this.supportsThinking() || effectiveLevel !== "off") { - this.settingsManager.setDefaultThinkingLevel(effectiveLevel); - } this._emit({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", @@ -1717,7 +1742,7 @@ export class AgentSession { * Cycle to next thinking level. * @returns New level, or undefined if model doesn't support thinking */ - cycleThinkingLevel(): ThinkingLevel | undefined { + cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined { if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); @@ -1725,7 +1750,7 @@ export class AgentSession { const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; - this.setThinkingLevel(nextLevel); + this.setThinkingLevel(nextLevel, options); return nextLevel; } @@ -1734,7 +1759,7 @@ export class AgentSession { * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels(): ThinkingLevel[] { - if (!this.model) return THINKING_LEVELS; + if (!this.model) return [...THINKING_LEVEL_OPTIONS]; return getSupportedThinkingLevels(this.model) as ThinkingLevel[]; } @@ -1745,10 +1770,17 @@ export class AgentSession { return !!this.model?.reasoning; } - private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + private _getThinkingLevelForModelSwitch(targetModel?: Model, explicitLevel?: ThinkingLevel): ThinkingLevel { if (explicitLevel !== undefined) { return explicitLevel; } + // Per-model default takes priority when switching to a model that has one + if (targetModel) { + const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id); + if (perModel !== undefined) { + return perModel; + } + } if (!this.supportsThinking()) { return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } diff --git a/packages/coding-agent/src/core/defaults.ts b/packages/coding-agent/src/core/defaults.ts index fddc7d14f..1e15c40e0 100644 --- a/packages/coding-agent/src/core/defaults.ts +++ b/packages/coding-agent/src/core/defaults.ts @@ -1,3 +1,12 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; +export const THINKING_LEVEL_OPTIONS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 80a84f963..4bf4ac541 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -626,6 +626,7 @@ export async function findInitialModel(options: { defaultProvider?: string; defaultModelId?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; modelRuntime: ModelRuntime; }): Promise { const { @@ -636,6 +637,7 @@ export async function findInitialModel(options: { defaultProvider, defaultModelId, defaultThinkingLevel, + modelThinkingLevels, modelRuntime, } = options; @@ -660,9 +662,11 @@ export async function findInitialModel(options: { // 2. Use first model from scoped models (skip if continuing/resuming) if (scopedModels.length > 0 && !isContinuing) { + const scopedModel = scopedModels[0]; + const perModel = modelThinkingLevels?.[`${scopedModel.model.provider}/${scopedModel.model.id}`]; return { - model: scopedModels[0].model, - thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, + model: scopedModel.model, + thinkingLevel: scopedModel.thinkingLevel ?? perModel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, fallbackMessage: undefined, }; } @@ -672,7 +676,10 @@ export async function findInitialModel(options: { const found = modelRuntime.getModel(defaultProvider, defaultModelId); if (found && modelRuntime.hasConfiguredAuth(found.provider)) { model = found; - if (defaultThinkingLevel) { + const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`]; + if (perModel) { + thinkingLevel = perModel; + } else if (defaultThinkingLevel) { thinkingLevel = defaultThinkingLevel; } return { model, thinkingLevel, fallbackMessage: undefined }; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a9a266418..8af041a57 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -213,6 +213,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} defaultProvider: settingsManager.getDefaultProvider(), defaultModelId: settingsManager.getDefaultModel(), defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelThinkingLevels: settingsManager.getAllModelThinkingLevels(), modelRuntime, }); model = result.model; @@ -232,7 +233,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); } - // Fall back to settings default + // Fall back to per-model override, then global default + if (thinkingLevel === undefined && model) { + const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id); + if (perModel) { + thinkingLevel = perModel; + } + } if (thinkingLevel === undefined) { thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index fa2700640..19d59483e 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -93,6 +93,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; // per-model default thinking level overrides keyed by "provider/modelId" transport?: TransportSetting; // default: "auto" steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time"; @@ -784,6 +785,33 @@ export class SettingsManager { this.save(); } + getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined { + return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`]; + } + + getAllModelThinkingLevels(): Record { + return { ...(this.settings.modelThinkingLevels ?? {}) }; + } + + setModelThinkingLevel(provider: string, modelId: string, level: ThinkingLevel): void { + if (!this.globalSettings.modelThinkingLevels) { + this.globalSettings.modelThinkingLevels = {}; + } + this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`] = level; + this.markModified("modelThinkingLevels"); + this.save(); + } + + removeModelThinkingLevel(provider: string, modelId: string): void { + if (!this.globalSettings.modelThinkingLevels) return; + delete this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`]; + if (Object.keys(this.globalSettings.modelThinkingLevels).length === 0) { + delete this.globalSettings.modelThinkingLevels; + } + this.markModified("modelThinkingLevels"); + this.save(); + } + getTransport(): TransportSetting { return this.settings.transport ?? "auto"; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 7204988fe..edf2b4987 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -18,7 +18,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, - { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] " }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 1d5e58b12..efa96ffa4 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -10,7 +10,6 @@ import { type TUI, } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; -import type { SettingsManager } from "../../../core/settings-manager.ts"; import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; @@ -52,7 +51,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private filteredModels: ModelItem[] = []; private selectedIndex: number = 0; private currentModel?: Model; - private settingsManager: SettingsManager; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; private onCancelCallback: () => void; @@ -71,7 +69,6 @@ export class ModelSelectorComponent extends Container implements Focusable { constructor( tui: TUI, currentModel: Model | undefined, - settingsManager: SettingsManager, modelRuntime: ModelRuntime, scopedModels: ReadonlyArray, onSelect: (model: Model) => void, @@ -82,7 +79,6 @@ export class ModelSelectorComponent extends Container implements Focusable { this.tui = tui; this.currentModel = currentModel; - this.settingsManager = settingsManager; this.modelRuntime = modelRuntime; this.scopedModels = scopedModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; @@ -363,8 +359,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private handleSelect(model: Model): void { this.dispose(); - // Save as new default - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.onSelectCallback(model); } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1d626270e..5fe374c50 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,13 +1,11 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Transport } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels, type Model, type Transport } from "@earendil-works/pi-ai"; import { type Component, Container, getCapabilities, type ScrollViewScrollbar, type SelectItem, - SelectList, - type SelectListLayoutOptions, type SettingItem, SettingsList, Spacer, @@ -21,20 +19,13 @@ import type { TuiMode, WarningSettings, } from "../../../core/settings-manager.ts"; -import { - getSelectListTheme, - getSettingsListTheme, - parseAutoThemeSetting, - type TerminalTheme, - theme, -} from "../theme/theme.ts"; +import { getSettingsListTheme, parseAutoThemeSetting, type TerminalTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; +import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settings-submenu.ts"; -const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { - minPrimaryColumnWidth: 12, - maxPrimaryColumnWidth: 32, -}; +const NO_DEFAULT_MODEL_VALUE = "__none__"; +const NO_DEFAULT_MODEL_LABEL = "not set"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", @@ -58,6 +49,8 @@ const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( export interface SettingsConfig { autoCompact: boolean; + defaultModel: string; + availableDefaultModels: readonly Model[]; showImages: boolean; imageWidthCells: number; autoResizeImages: boolean; @@ -69,6 +62,7 @@ export interface SettingsConfig { httpIdleTimeoutMs: number; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; + modelThinkingLevels: Record; currentTheme: string; terminalTheme: TerminalTheme; availableThemes: string[]; @@ -95,6 +89,7 @@ export interface SettingsConfig { export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; + onDefaultModelChange: (model: Model) => Promise; onShowImagesChange: (enabled: boolean) => void; onImageWidthCellsChange: (width: number) => void; onAutoResizeImagesChange: (enabled: boolean) => void; @@ -105,6 +100,8 @@ export interface SettingsCallbacks { onTransportChange: (transport: Transport) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; onThinkingLevelChange: (level: ThinkingLevel) => void; + onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void; + onModelThinkingLevelRemove: (provider: string, modelId: string) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; @@ -174,68 +171,34 @@ class WarningSettingsSubmenu extends Container { } } -class SelectSubmenu extends Container { - private selectList: SelectList; +const CLEAR_OVERRIDE_VALUE = "__clear__"; - constructor( - title: string, - description: string, - options: SelectItem[], - currentValue: string, - onSelect: (value: string) => void, - onCancel: () => void, - onSelectionChange?: (value: string) => void, - ) { - super(); +function modelSettingKey(model: Model): string { + return `${model.provider}/${model.id}`; +} - // Title - this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); +function defaultModelDisplayValue(defaultModel: string, overrides: Record): string { + const override = overrides[defaultModel]; + return override ? `${defaultModel} \u00b7 ${override}` : defaultModel; +} - // Description - if (description) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", description), 0, 0)); - } +function modelThinkingOverridesSummary(overrides: Record): string { + const count = Object.keys(overrides).length; + if (count === 0) return "none"; + return `${count} configured`; +} - // Spacer - this.addChild(new Spacer(1)); - - // Select list - this.selectList = new SelectList( - options, - Math.min(options.length, 10), - getSelectListTheme(), - SETTINGS_SUBMENU_SELECT_LIST_LAYOUT, - ); - - // Pre-select current value - const currentIndex = options.findIndex((o) => o.value === currentValue); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value); - }; - - this.selectList.onCancel = onCancel; - - if (onSelectionChange) { - this.selectList.onSelectionChange = (item) => { - onSelectionChange(item.value); - }; - } - - this.addChild(this.selectList); - - // Hint - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); - } - - handleInput(data: string): void { - this.selectList.handleInput(data); - } +function defaultModelItems(models: readonly Model[]): SelectItem[] { + return [...models] + .sort((a, b) => { + const providerCompare = a.provider.localeCompare(b.provider); + if (providerCompare !== 0) return providerCompare; + return (a.name || a.id).localeCompare(b.name || b.id); + }) + .map((model) => { + const key = modelSettingKey(model); + return { value: key, label: key, description: model.name }; + }); } function themeItems(availableThemes: string[]): SelectItem[] { @@ -493,6 +456,15 @@ export class SettingsSelectorComponent extends Container { const supportsImages = getCapabilities().images; const followUpKey = keyDisplayText("app.message.followUp"); let currentWarnings = { ...config.warnings }; + const currentModelThinkingLevels = { ...config.modelThinkingLevels }; + let lastSelectedDefaultModel: Model | undefined; + const defaultModelOptions = defaultModelItems(config.availableDefaultModels); + const defaultModelByValue = new Map( + config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), + ); + let currentDefaultModelKey: string | undefined = defaultModelByValue.has(config.defaultModel) + ? config.defaultModel + : undefined; const items: SettingItem[] = [ { @@ -524,6 +496,48 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, + { + id: "default-model", + label: "Default model", + description: "Startup model for new sessions", + currentValue: defaultModelDisplayValue(config.defaultModel, currentModelThinkingLevels), + submenu: (currentValue, done) => { + const options = + defaultModelOptions.length > 0 + ? defaultModelOptions + : [ + { + value: NO_DEFAULT_MODEL_VALUE, + label: "No models available", + description: "Log in to a provider or configure an API key first", + }, + ]; + return new SelectSubmenu( + "Default Model", + "Select the model to use when starting new sessions", + options, + currentValue === NO_DEFAULT_MODEL_LABEL ? NO_DEFAULT_MODEL_VALUE : currentValue, + (value) => { + const model = defaultModelByValue.get(value); + if (!model) { + done(); + return; + } + void callbacks.onDefaultModelChange(model).then( + () => { + lastSelectedDefaultModel = model; + currentDefaultModelKey = value; + done(defaultModelDisplayValue(value, currentModelThinkingLevels), { + navigateTo: "model-thinking", + }); + }, + () => done(), + ); + }, + () => done(), + ); + }, + }, { id: "http-idle-timeout", label: "HTTP idle timeout", @@ -612,13 +626,13 @@ export class SettingsSelectorComponent extends Container { }, { id: "thinking", - label: "Thinking level", - description: "Reasoning depth for thinking-capable models", + label: "Default thinking level", + description: "Startup reasoning depth for thinking-capable models", currentValue: config.thinkingLevel, submenu: (currentValue, done) => new SelectSubmenu( - "Thinking Level", - "Select reasoning depth for thinking-capable models", + "Default Thinking Level", + "Select the reasoning depth to use when starting new sessions", config.availableThinkingLevels.map((level) => ({ value: level, label: level, @@ -632,6 +646,116 @@ export class SettingsSelectorComponent extends Container { () => done(), ), }, + { + id: "model-thinking", + label: "Default thinking level per model", + description: "Override the default thinking level for specific models", + currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), + submenu: (_currentValue, done) => { + const preselected = lastSelectedDefaultModel; + lastSelectedDefaultModel = undefined; + + const steps: SteppedSubmenuStep[] = [ + { + key: "model", + title: "Per-Model Thinking Level", + description: "Select a model to configure", + options: () => { + const sorted = [...config.availableDefaultModels].sort((a, b) => { + const aKey = modelSettingKey(a); + const bKey = modelSettingKey(b); + if (aKey === currentDefaultModelKey) return -1; + if (bKey === currentDefaultModelKey) return 1; + const pc = a.provider.localeCompare(b.provider); + if (pc !== 0) return pc; + return (a.name || a.id).localeCompare(b.name || b.id); + }); + const items: SelectItem[] = sorted.map((model) => { + const key = modelSettingKey(model); + const override = currentModelThinkingLevels[key]; + const isDefault = key === currentDefaultModelKey; + const desc = [ + isDefault ? "default model" : undefined, + override ? `thinking: ${override}` : undefined, + ] + .filter(Boolean) + .join(" \u00b7 "); + return { value: key, label: key, description: desc || undefined }; + }); + if (items.length === 0) { + items.push({ + value: "__none__", + label: "No models available", + description: "Log in to a provider or configure an API key first", + }); + } + return items; + }, + preselect: () => currentDefaultModelKey, + }, + { + key: "level", + title: (ctx) => `Thinking Level for ${ctx.model}`, + description: "Select default thinking level for this model", + options: (ctx) => { + const model = defaultModelByValue.get(ctx.model); + if (!model) return []; + const levels = ( + model.reasoning ? getSupportedThinkingLevels(model) : ["off"] + ) as ThinkingLevel[]; + const items: SelectItem[] = levels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })); + if (currentModelThinkingLevels[ctx.model] !== undefined) { + items.push({ + value: CLEAR_OVERRIDE_VALUE, + label: "(clear override)", + description: `Revert to global default (${config.thinkingLevel})`, + }); + } + return items; + }, + preselect: (ctx) => currentModelThinkingLevels[ctx.model], + }, + ]; + + const summary = () => modelThinkingOverridesSummary(currentModelThinkingLevels); + + return new SteppedSubmenu( + steps, + (selections) => { + const model = defaultModelByValue.get(selections.model); + if (!model) return; + if (selections.level === CLEAR_OVERRIDE_VALUE) { + callbacks.onModelThinkingLevelRemove(model.provider, model.id); + delete currentModelThinkingLevels[selections.model]; + } else { + callbacks.onModelThinkingLevelChange( + model.provider, + model.id, + selections.level as ThinkingLevel, + ); + currentModelThinkingLevels[selections.model] = selections.level as ThinkingLevel; + } + }, + () => { + this.settingsList.updateValue( + "default-model", + defaultModelDisplayValue( + currentDefaultModelKey ?? config.defaultModel, + currentModelThinkingLevels, + ), + ); + done(summary()); + }, + preselected + ? { startAtStep: 1, initialContext: { model: modelSettingKey(preselected) } } + : { loop: true }, + ); + }, + }, { id: "tui-mode", label: "TUI mode", diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts new file mode 100644 index 000000000..bc6a02244 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -0,0 +1,196 @@ +import { + type Component, + Container, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; + +const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +/** + * Single-step submenu that shows a titled select list. + */ +export class SelectSubmenu extends Container { + private selectList: SelectList; + + constructor( + title: string, + description: string, + options: SelectItem[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + onSelectionChange?: (value: string) => void, + ) { + super(); + + // Title + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + + // Description + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + + // Spacer + this.addChild(new Spacer(1)); + + // Select list + this.selectList = new SelectList( + options, + Math.min(options.length, 10), + getSelectListTheme(), + SUBMENU_SELECT_LIST_LAYOUT, + ); + + // Pre-select current value + const currentIndex = options.findIndex((o) => o.value === currentValue); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = onCancel; + + if (onSelectionChange) { + this.selectList.onSelectionChange = (item) => { + onSelectionChange(item.value); + }; + } + + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +// ============================================================================ +// SteppedSubmenu — reusable multi-step selector +// ============================================================================ + +/** One step in a {@link SteppedSubmenu}. */ +export interface SteppedSubmenuStep { + /** Unique key — the selected value is stored in the result context under this key. */ + key: string; + /** Title shown at the top of the step. Receives prior selections. */ + title: string | ((context: Record) => string); + /** Description shown below the title. Receives prior selections. */ + description: string | ((context: Record) => string); + /** Build the option list for this step. Called fresh each time the step is shown. */ + options: (context: Record) => SelectItem[]; + /** Optionally pre-select a value when entering this step. */ + preselect?: (context: Record) => string | undefined; +} + +interface SteppedSubmenuOptions { + /** Start at this step index (0-based), skipping earlier steps. Requires initialContext for skipped keys. */ + startAtStep?: number; + /** Pre-fill selections for skipped steps. */ + initialContext?: Record; + /** After completing the last step, loop back to step 0 instead of closing. */ + loop?: boolean; +} + +/** + * Generic N-step submenu built on top of {@link SelectSubmenu}. + * + * Each step's options can depend on prior selections via the shared context. + * Esc goes back one step; Esc at step 0 cancels. + * With `loop: true`, completing the final step invokes `onComplete` then returns to step 0. + */ +export class SteppedSubmenu extends Container { + private readonly steps: SteppedSubmenuStep[]; + private readonly onComplete: (context: Record) => void; + private readonly onCancel: () => void; + private readonly opts: SteppedSubmenuOptions; + private activeComponent: Component; + private context: Record; + + constructor( + steps: SteppedSubmenuStep[], + onComplete: (context: Record) => void, + onCancel: () => void, + opts: SteppedSubmenuOptions = {}, + ) { + super(); + this.steps = steps; + this.onComplete = onComplete; + this.onCancel = onCancel; + this.opts = opts; + this.context = { ...(opts.initialContext ?? {}) }; + this.activeComponent = this.buildStep(opts.startAtStep ?? 0); + } + + private buildStep(stepIndex: number): Component { + const step = this.steps[stepIndex]; + const total = this.steps.length; + const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} · ` : ""; + + const title = typeof step.title === "function" ? step.title(this.context) : step.title; + const desc = typeof step.description === "function" ? step.description(this.context) : step.description; + const items = step.options(this.context); + const preselect = step.preselect?.(this.context) ?? ""; + + return new SelectSubmenu( + title, + `${stepLabel}${desc}`, + items, + preselect, + (value) => { + this.context[step.key] = value; + + if (stepIndex < total - 1) { + // Advance to next step + this.activeComponent = this.buildStep(stepIndex + 1); + } else { + // Final step — deliver result + this.onComplete({ ...this.context }); + + if (this.opts.loop) { + this.context = {}; + this.activeComponent = this.buildStep(0); + } else { + this.onCancel(); + } + } + }, + () => { + if (stepIndex > 0) { + delete this.context[step.key]; + this.activeComponent = this.buildStep(stepIndex - 1); + } else { + this.onCancel(); + } + }, + ); + } + + render(width: number): string[] { + return this.activeComponent.render(width); + } + + handleInput(data: string): void { + this.activeComponent.handleInput?.(data); + } + + invalidate(): void { + this.activeComponent.invalidate?.(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b008f0bb7..ea80883e9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -66,6 +66,7 @@ import { computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts"; import type { AutocompleteProviderFactory, EditorFactory, @@ -231,6 +232,44 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } +export interface ModelCommandArgs { + searchTerm?: string; + persist: boolean; + error?: string; +} + +export function parseModelCommandArgs(args: string | undefined): ModelCommandArgs { + if (!args?.trim()) return { persist: false }; + + let persist = false; + const searchTerms: string[] = []; + for (const token of args.trim().split(/\s+/u)) { + if (token === "--default") { + persist = true; + continue; + } + + if (token.startsWith("--default=")) { + persist = true; + const value = token.slice("--default=".length); + if (value) searchTerms.push(value); + continue; + } + + if (token.startsWith("--")) { + return { + persist, + searchTerm: searchTerms.join(" ") || undefined, + error: `Unknown /model option "${token}". Supported option: --default.`, + }; + } + + searchTerms.push(token); + } + + return { persist, searchTerm: searchTerms.join(" ") || undefined }; +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; @@ -681,6 +720,16 @@ export class InteractiveMode { const modelCommand = slashCommands.find((command) => command.name === "model"); if (modelCommand) { modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const trimmedPrefix = prefix.trimStart(); + if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { + return "--default".startsWith(trimmedPrefix) + ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] + : null; + } + + const parsed = parseModelCommandArgs(prefix); + if (parsed.error) return null; + const models = this.session.scopedModels.length > 0 ? this.session.scopedModels.map((s) => s.model) @@ -696,10 +745,11 @@ export class InteractiveMode { label: `${m.provider}/${m.id}`, })); - return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({ - value: item.label, + const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; + return createFuzzyAutocompleteItems(items, searchPrefix, getModelSearchText, (item) => ({ + value: parsed.persist ? `--default ${item.label}` : item.label, label: item.id, - description: item.provider, + description: parsed.persist ? `${item.provider} · set as startup default` : item.provider, })); }; } @@ -2950,9 +3000,13 @@ export class InteractiveMode { return; } if (text === "/model" || text.startsWith("/model ")) { - const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; + const args = parseModelCommandArgs(text.startsWith("/model ") ? text.slice(7).trim() : undefined); this.editor.setText(""); - await this.handleModelCommand(searchTerm); + if (args.error) { + this.showError(args.error); + return; + } + await this.handleModelCommand(args.searchTerm, { persist: args.persist }); return; } if (text === "/export" || text.startsWith("/export ")) { @@ -4493,9 +4547,14 @@ export class InteractiveMode { private showSettingsSelector(): void { this.showSelector((done) => { let selector: SettingsSelectorComponent | undefined; + const defaultProvider = this.settingsManager.getDefaultProvider(); + const defaultModelId = this.settingsManager.getDefaultModel(); + const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set"; selector = new SettingsSelectorComponent( { autoCompact: this.session.autoCompactionEnabled, + defaultModel, + availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(), showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), autoResizeImages: this.settingsManager.getImageAutoResize(), @@ -4505,8 +4564,9 @@ export class InteractiveMode { followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), - thinkingLevel: this.session.thinkingLevel, - availableThinkingLevels: this.session.getAvailableThinkingLevels(), + thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, + availableThinkingLevels: [...THINKING_LEVEL_OPTIONS], + modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(), currentTheme: this.themeController.getThemeSelection() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), @@ -4535,6 +4595,18 @@ export class InteractiveMode { this.session.setAutoCompactionEnabled(enabled); this.footer.setAutoCompactEnabled(enabled); }, + onDefaultModelChange: async (model) => { + try { + await this.session.setModel(model, { persist: true }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(`Default model: ${model.provider}/${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }, onShowImagesChange: (enabled) => { this.settingsManager.setShowImages(enabled); for (const child of this.chatContainer.children) { @@ -4577,10 +4649,31 @@ export class InteractiveMode { this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, onThinkingLevelChange: (level) => { - this.session.setThinkingLevel(level); + this.session.setThinkingLevel(level, { persist: true }); this.footer.invalidate(); this.updateEditorBorderColor(); }, + onModelThinkingLevelChange: (provider, modelId, level) => { + this.settingsManager.setModelThinkingLevel(provider, modelId, level); + // If the override is for the current model, apply it to the session too + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, + onModelThinkingLevelRemove: (provider, modelId) => { + this.settingsManager.removeModelThinkingLevel(provider, modelId); + // If the override was for the current model, revert to global default + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + this.session.setThinkingLevel(globalDefault); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); void this.themeController.setThemeSetting(themeSetting); @@ -4703,19 +4796,19 @@ export class InteractiveMode { }); } - private async handleModelCommand(searchTerm?: string): Promise { + private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise { if (!searchTerm) { - this.showModelSelector(); + this.showModelSelector(undefined, options); return; } const model = await this.findExactModelMatch(searchTerm); if (model) { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: options.persist === true }); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(`Model: ${model.id}`); + this.showStatus(options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -4724,7 +4817,7 @@ export class InteractiveMode { return; } - this.showModelSelector(searchTerm); + this.showModelSelector(searchTerm, options); } private async findExactModelMatch(searchTerm: string): Promise | undefined> { @@ -4852,21 +4945,22 @@ export class InteractiveMode { }); } - private showModelSelector(initialSearchInput?: string): void { + private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( this.ui, this.session.model, - this.settingsManager, this.session.modelRuntime, this.session.scopedModels, async (model) => { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: options.persist === true }); this.footer.invalidate(); this.updateEditorBorderColor(); done(); - this.showStatus(`Model: ${model.id}`); + this.showStatus( + options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`, + ); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -5558,7 +5652,7 @@ export class InteractiveMode { selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; } else { try { - await this.session.setModel(selectedModel); + await this.session.setModel(selectedModel, { persist: true }); } catch (error: unknown) { selectedModel = undefined; const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index a4aaa48c0..cd6a0b272 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { InteractiveMode, parseModelCommandArgs } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { @@ -401,6 +401,22 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { + describe("parseModelCommandArgs", () => { + test("parses --default as a persistent model selection", () => { + expect(parseModelCommandArgs("--default openai/gpt-5")).toEqual({ + persist: true, + searchTerm: "openai/gpt-5", + }); + }); + + test("rejects unknown /model flags", () => { + expect(parseModelCommandArgs("--global openai/gpt-5")).toMatchObject({ + persist: false, + error: 'Unknown /model option "--global". Supported option: --default.', + }); + }); + }); + test("matches model command arguments across provider/model order", async () => { type TestModel = { id: string; provider: string; name: string }; type FakeInteractiveMode = { @@ -452,6 +468,52 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { ]); }); + test("preserves --default when completing model command arguments", async () => { + type TestModel = { id: string; provider: string; name: string }; + type FakeInteractiveMode = { + session: { + scopedModels: Array<{ model: TestModel }>; + modelRuntime: { getAvailableSnapshot: () => TestModel[] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: null; + }; + + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const models = [{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }]; + const fakeThis: FakeInteractiveMode = { + session: { + scopedModels: [], + modelRuntime: { getAvailableSnapshot: () => models }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => "/tmp" }, + fdPath: null, + }; + + const provider = createBaseAutocompleteProvider.call(fakeThis); + const line = "/model --default codex"; + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.prefix).toBe("--default codex"); + expect(suggestions?.items[0]?.value).toBe("--default openai-codex/gpt-5.5"); + }); + test("matches login command arguments by provider id and name", async () => { type FakeInteractiveMode = { session: { diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index 060386ee7..fd5550ac1 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -34,7 +34,6 @@ describe("model selector", () => { const selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), - harness.settingsManager, harness.session.modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index e658223b9..9c88fd282 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => { fullscreenExitOutput: "transcript", fullscreenScrollbar: "auto", warnings: {}, + defaultModel: "not set", + availableDefaultModels: [], availableThinkingLevels: [], + modelThinkingLevels: {}, availableThemes: [], } as unknown as SettingsConfig; const callbacks = { diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index b334b1232..487f55e0c 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -14,7 +14,7 @@ describe("AgentSession model and extension characterization", () => { } }); - it("setModel saves the model and emits model_select", async () => { + it("setModel saves the model to the session and emits model_select", async () => { const modelEvents: string[] = []; const harness = await createHarness({ models: [ @@ -42,6 +42,112 @@ describe("AgentSession model and extension characterization", () => { .filter((entry) => entry.type === "model_change") .map((entry) => `${entry.provider}/${entry.modelId}`), ).toEqual([`${nextModel.provider}/${nextModel.id}`]); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + }); + + it("only persists model and thinking defaults when requested", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + const nextModel = harness.getModel("faux-2")!; + + await harness.session.setModel(nextModel); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + + harness.session.setThinkingLevel("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBeUndefined(); + + await harness.session.setModel(nextModel, { persist: true }); + expect(harness.settingsManager.getDefaultProvider()).toBe(nextModel.provider); + expect(harness.settingsManager.getDefaultModel()).toBe(nextModel.id); + + harness.session.setThinkingLevel("high", { persist: true }); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("high"); + }); + + it("persists the requested default thinking level even when the current model clamps it", async () => { + const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] }); + harnesses.push(harness); + + harness.session.setThinkingLevel("max", { persist: true }); + + expect(harness.session.thinkingLevel).toBe("high"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("max"); + }); + + it("cycleModel and cycleThinkingLevel are session-only by default", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultProvider: "faux", + defaultModel: "faux-1", + defaultThinkingLevel: "low", + }, + }); + harnesses.push(harness); + + await harness.session.cycleModel(); + expect(harness.session.model?.id).toBe("faux-2"); + expect(harness.settingsManager.getDefaultModel()).toBe("faux-1"); + + harness.session.setThinkingLevel("off"); + expect(harness.session.cycleThinkingLevel()).toBe("minimal"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low"); + }); + + it("applies per-model thinking level override on model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + + // Set a per-model override for faux-2 + harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low"); + + // Session starts on faux-1 with default thinking + harness.session.setThinkingLevel("high"); + expect(harness.session.thinkingLevel).toBe("high"); + + // Switch to faux-2 → per-model override should apply + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("low"); + + // Switch back to faux-1 → no per-model override, carries session level + const model1 = harness.getModel("faux-1")!; + await harness.session.setModel(model1); + expect(harness.session.thinkingLevel).toBe("low"); + }); + + it("per-model override takes priority over global default during model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultThinkingLevel: "high", + modelThinkingLevels: { "faux/faux-2": "minimal" }, + }, + }); + harnesses.push(harness); + + // Start on a non-thinking model, then switch to faux-2 + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("minimal"); }); it("cycles through scoped models and preserves the scoped thinking preference", async () => { diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index c44e1b36f..e9f33f90c 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -78,7 +78,6 @@ describe("issue #3217 scoped model ordering", () => { const selector = new ModelSelectorComponent( createFakeTui(), modelOne, - harness.settingsManager, harness.session.modelRuntime, [{ model: modelTwo }, { model: modelOne }, { model: modelThree }], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts index 8a4c144bc..2f64c52a7 100644 --- a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts +++ b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts @@ -5,7 +5,6 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../../../src/core/auth-storage.ts"; import { KeybindingsManager } from "../../../src/core/keybindings.ts"; -import { SettingsManager } from "../../../src/core/settings-manager.ts"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../../src/utils/ansi.ts"; @@ -69,7 +68,6 @@ describe("issue #6999 models.json hot reload", () => { const selector = new ModelSelectorComponent( tui, undefined, - SettingsManager.inMemory(), modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index 84551ddfb..e853ac35a 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -51,7 +51,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), current, - harness.settingsManager, harness.session.modelRuntime, [], () => {}, @@ -102,7 +101,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), alpha1, - harness.settingsManager, harness.session.modelRuntime, [{ model: alpha2 }, { model: alpha3 }, { model: alpha1 }], () => {}, diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7dce91b87..72f9aece2 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -15,8 +15,12 @@ export interface SettingItem { currentValue: string; /** If provided, Enter/Space cycles through these values */ values?: string[]; - /** If provided, Enter opens this submenu. Receives current value and done callback. */ - submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; + /** If provided, Enter opens this submenu. Receives current value and done callback. + * done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */ + submenu?: ( + currentValue: string, + done: (selectedValue?: string, options?: { navigateTo?: string }) => void, + ) => Component; } export interface SettingsListTheme { @@ -45,6 +49,7 @@ export class SettingsList implements Component { // Submenu state private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null; + private navigateAfterClose: string | null = null; constructor( items: SettingItem[], @@ -74,6 +79,15 @@ export class SettingsList implements Component { } } + /** Move selection to the item with the given id (no-op if not found). */ + selectItem(id: string): void { + const items = this.searchEnabled ? this.filteredItems : this.items; + const index = items.findIndex((i) => i.id === id); + if (index !== -1) { + this.selectedIndex = index; + } + } + invalidate(): void { this.submenuComponent?.invalidate?.(); } @@ -202,13 +216,19 @@ export class SettingsList implements Component { if (item.submenu) { // Open submenu, passing current value so it can pre-select correctly this.submenuItemIndex = this.selectedIndex; - this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { - if (selectedValue !== undefined) { - item.currentValue = selectedValue; - this.onChange(item.id, selectedValue); - } - this.closeSubmenu(); - }); + this.submenuComponent = item.submenu( + item.currentValue, + (selectedValue?: string, options?: { navigateTo?: string }) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + if (options?.navigateTo) { + this.navigateAfterClose = options.navigateTo; + } + this.closeSubmenu(); + }, + ); } else if (item.values && item.values.length > 0) { // Cycle through values const currentIndex = item.values.indexOf(item.currentValue); @@ -221,8 +241,15 @@ export class SettingsList implements Component { private closeSubmenu(): void { this.submenuComponent = null; - // Restore selection to the item that opened the submenu - if (this.submenuItemIndex !== null) { + if (this.navigateAfterClose !== null) { + const id = this.navigateAfterClose; + this.navigateAfterClose = null; + this.submenuItemIndex = null; + this.selectItem(id); + // Open the target item's submenu automatically + this.activateItem(); + } else if (this.submenuItemIndex !== null) { + // Restore selection to the item that opened the submenu this.selectedIndex = this.submenuItemIndex; this.submenuItemIndex = null; }