mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
4ca636c5e0
commit
2ff8ba6223
18 changed files with 836 additions and 153 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ describe("model selector", () => {
|
|||
const selector = new ModelSelectorComponent(
|
||||
createFakeTui(),
|
||||
harness.getModel(),
|
||||
harness.settingsManager,
|
||||
harness.session.modelRuntime,
|
||||
[],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => {
|
|||
fullscreenExitOutput: "transcript",
|
||||
fullscreenScrollbar: "auto",
|
||||
warnings: {},
|
||||
defaultModel: "not set",
|
||||
availableDefaultModels: [],
|
||||
availableThinkingLevels: [],
|
||||
modelThinkingLevels: {},
|
||||
availableThemes: [],
|
||||
} as unknown as SettingsConfig;
|
||||
const callbacks = {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
[],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue