fix(coding-agent): keep model and thinking level changes session scoped (#8356)

* fix(coding-agent): keep model and thinking level changes session scoped

* fix(coding-agent): persist requested default thinking level

* feat: --default arg to persist

* fix

* fix(coding-agent): keep model and thinking level changes session scoped

* fix: make default thinking level per model show list of models

* cleanup export
This commit is contained in:
Cristina Poncela Cubeiro 2026-08-19 16:48:29 +02:00 • committed by GitHub
parent 4ca636c5e0
commit 2ff8ba6223
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 836 additions and 153 deletions

View file

@ -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<any>;
@ -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<any>): Promise<void> {
async setModel(model: Model<any>, options: ModelMutationOptions = {}): Promise<void> {
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<ModelCycleResult | undefined> {
async cycleModel(
direction: "forward" | "backward" = "forward",
options: ModelMutationOptions = {},
): Promise<ModelCycleResult | undefined> {
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<ModelCycleResult | undefined> {
private async _cycleScopedModel(
direction: "forward" | "backward",
options: ModelMutationOptions,
): Promise<ModelCycleResult | undefined> {
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<ModelCycleResult | undefined> {
private async _cycleAvailableModel(
direction: "forward" | "backward",
options: ModelMutationOptions,
): Promise<ModelCycleResult | undefined> {
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<any>, 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;
}

View file

@ -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",
];

View file

@ -626,6 +626,7 @@ export async function findInitialModel(options: {
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingLevel?: ThinkingLevel;
modelThinkingLevels?: Record<string, ThinkingLevel>;
modelRuntime: ModelRuntime;
}): Promise<InitialModelResult> {
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 };

View file

@ -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;
}

View file

@ -93,6 +93,7 @@ export interface Settings {
defaultProvider?: string;
defaultModel?: string;
defaultThinkingLevel?: ThinkingLevel;
modelThinkingLevels?: Record<string, ThinkingLevel>; // 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<string, ThinkingLevel> {
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";
}

View file

@ -18,7 +18,7 @@ export interface BuiltinSlashCommand {
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] <provider/model>" },
{ 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" },

View file

@ -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<any>;
private settingsManager: SettingsManager;
private modelRuntime: ModelRuntime;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
@ -71,7 +69,6 @@ export class ModelSelectorComponent extends Container implements Focusable {
constructor(
tui: TUI,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRuntime: ModelRuntime,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => 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<any>): void {
this.dispose();
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
}

View file

@ -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<ThinkingLevel, string> = {
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<any>[];
showImages: boolean;
imageWidthCells: number;
autoResizeImages: boolean;
@ -69,6 +62,7 @@ export interface SettingsConfig {
httpIdleTimeoutMs: number;
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
modelThinkingLevels: Record<string, ThinkingLevel>;
currentTheme: string;
terminalTheme: TerminalTheme;
availableThemes: string[];
@ -95,6 +89,7 @@ export interface SettingsConfig {
export interface SettingsCallbacks {
onAutoCompactChange: (enabled: boolean) => void;
onDefaultModelChange: (model: Model<any>) => Promise<void>;
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<any>): 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, ThinkingLevel>): 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, ThinkingLevel>): 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<any>[]): 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<any> | 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",

View file

@ -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, string>) => string);
/** Description shown below the title. Receives prior selections. */
description: string | ((context: Record<string, string>) => string);
/** Build the option list for this step. Called fresh each time the step is shown. */
options: (context: Record<string, string>) => SelectItem[];
/** Optionally pre-select a value when entering this step. */
preselect?: (context: Record<string, string>) => 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<string, string>;
/** 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<string, string>) => void;
private readonly onCancel: () => void;
private readonly opts: SteppedSubmenuOptions;
private activeComponent: Component;
private context: Record<string, string>;
constructor(
steps: SteppedSubmenuStep[],
onComplete: (context: Record<string, string>) => 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?.();
}
}

View file

@ -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<void> {
private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise<void> {
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<Model<any> | 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);

View file

@ -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<string, string>;
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: {

View file

@ -34,7 +34,6 @@ describe("model selector", () => {
const selector = new ModelSelectorComponent(
createFakeTui(),
harness.getModel(),
harness.settingsManager,
harness.session.modelRuntime,
[],
() => {},

View file

@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => {
fullscreenExitOutput: "transcript",
fullscreenScrollbar: "auto",
warnings: {},
defaultModel: "not set",
availableDefaultModels: [],
availableThinkingLevels: [],
modelThinkingLevels: {},
availableThemes: [],
} as unknown as SettingsConfig;
const callbacks = {

View file

@ -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 () => {

View file

@ -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 }],
() => {},

View file

@ -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,
[],
() => {},

View file

@ -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 }],
() => {},

View file

@ -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;
}