Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added model profiles: save and switch between named model configurations (role assignments + thinking level) with `/profiles save|switch|delete|list`, browse profiles interactively with `/profiles` (no args), or press `ctrl+s` in the `/models` roles view.

## [16.4.6] - 2026-07-12

### Added
Expand Down
93 changes: 93 additions & 0 deletions packages/coding-agent/src/config/profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Model } from "@oh-my-pi/pi-ai";
import type { ConfiguredThinkingLevel } from "../thinking";
import { resolveModelRoleValue } from "./model-resolver";
import type { Settings } from "./settings";
import type { ProfileSnapshot } from "./settings-schema";

/** Get all profiles as a typed record. */
export function getProfiles(settings: Settings): Record<string, ProfileSnapshot> {
const value = settings.get("profiles");
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, ProfileSnapshot>;
}

/** Get sorted profile names. */
export function getProfileNames(settings: Settings): string[] {
return Object.keys(getProfiles(settings)).sort((a, b) => a.localeCompare(b));
}

/** Save the current model configuration as a named profile. Overwrites if exists. */
export function saveProfile(settings: Settings, name: string): void {
const modelRoles: Record<string, string> = {};
for (const [role, selector] of Object.entries(settings.getModelRoles())) {
if (selector) modelRoles[role] = selector;
}
const defaultThinkingLevel = settings.get("defaultThinkingLevel");
const snapshot: ProfileSnapshot = {
modelRoles,
defaultThinkingLevel,
};
const profiles = { ...getProfiles(settings) };
Comment thread
oldschoola marked this conversation as resolved.
Outdated
profiles[name] = snapshot;
settings.set("profiles", profiles);
}

/**
* Switch to a named profile. Replaces the entire modelRoles record and
* defaultThinkingLevel — clean cutover, NOT a merge. This ensures stale
* role assignments from the previously-active profile are removed.
*
* Returns true if the profile existed and was applied.
*/
export function switchProfile(settings: Settings, name: string): boolean {
const profiles = getProfiles(settings);
const profile = profiles[name];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: This lookup accepts inherited object keys as profiles. With the normal {} prototype, /profiles switch toString finds Object.prototype.toString, passes the truthiness guard, writes undefined as modelRoles, clears the runtime override, and reports success even though no such profile exists. deleteProfile() has the same issue via name in profiles. Require Object.hasOwn(profiles, name) and validate the snapshot shape before applying it; please cover prototype-key names in the missing-profile tests.

if (!profile) return false;
settings.set("modelRoles", profile.modelRoles);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply profile switches to runtime overrides

When a session was started with --model or role env overrides, those assignments are stored as runtime modelRoles overrides (main.ts:869, main.ts:1099), and Settings merges overrides after globals (settings.ts:1378). This line only writes the global modelRoles, so /profiles switch foo leaves the override winning; the handler then resolves the old overridden role and reports the profile as switched even though the effective role/model did not change. Replace or clear the runtime model-role override as part of switching profiles.

Useful? React with 👍 / 👎.

Comment thread
oldschoola marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: This does not replace the effective role record when project/config layers exist. Settings.set() writes only #global, then #rebuildMerged() recursively merges #project and #configOverlay over it (settings.ts:1375-1411). A project-scoped role omitted by the profile therefore survives, and a project-scoped default can override the profile entirely, contradicting the advertised clean cutover. Please apply the snapshot through an exact effective layer (or otherwise mask higher-layer keys) and cover a switch from a project-scoped stale role.

if (profile.defaultThinkingLevel !== undefined) {
settings.set("defaultThinkingLevel", profile.defaultThinkingLevel);
}
return true;
}

/** Delete a named profile. Returns true if it existed. */
export function deleteProfile(settings: Settings, name: string): boolean {
const profiles = { ...getProfiles(settings) };
if (!(name in profiles)) return false;
delete profiles[name];
settings.set("profiles", profiles);
return true;
}

export interface ProfileSwitchResult {
ok: boolean;
model?: Model;
thinkingLevel?: ConfiguredThinkingLevel;
}

/**
* Switch to a named profile and resolve the default role's model + thinking
* level from the available models. Returns the resolved model and thinking
* level so the caller can apply them to the live session.
*
* This is the single source of truth for profile switching — the slash
* command handlers and the interactive picker all call this.
*/
export function switchProfileAndResolve(
settings: Settings,
name: string,
availableModels: ReadonlyArray<Model>,
): ProfileSwitchResult {
const ok = switchProfile(settings, name);
if (!ok) return { ok: false };
const defaultRole = settings.getModelRole("default");
if (!defaultRole) return { ok: true };
Comment thread
oldschoola marked this conversation as resolved.
Outdated
const resolved = resolveModelRoleValue(defaultRole, availableModels as Model[], {
settings,
});
return {
ok: true,
model: resolved.model,
thinkingLevel: resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined,
Comment thread
oldschoola marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: The snapshot's defaultThinkingLevel is persisted above, but this drops it from the live switch whenever the default selector has no explicit :level. AgentSession.setModel() re-applies the target model's defaultLevel or preserves the current level (agent-session.ts:9071-9073); it does not read the newly written setting, and the reset branch has the same behavior. A profile saved with an unsuffixed model and defaultThinkingLevel: "low" can therefore switch models while keeping the old/model-default effort. Return/apply the snapshot's configured level here (including auto) and add a session-level regression test.

};
}
10 changes: 10 additions & 0 deletions packages/coding-agent/src/config/settings-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ export interface ModelTagsSettings {
[key: string]: ModelTagDef;
}

/** Snapshot of model configuration captured by a named profile. */
export interface ProfileSnapshot {
modelRoles: Record<string, string>;
defaultThinkingLevel: SettingValue<"defaultThinkingLevel">;
}

// Typed defaults for array/record settings — named constants avoid `as` casts
// under `as const` while still letting SettingValue infer the correct element type.
const EMPTY_STRING_ARRAY: string[] = [];
Expand All @@ -287,6 +293,7 @@ const EMPTY_NUMBER_RECORD: Record<string, number> = {};
const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc"];
const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
const EMPTY_PROFILES_RECORD: Record<string, ProfileSnapshot> = {};
const HINDSIGHT_RECALL_TYPES_DEFAULT: string[] = ["world", "experience"];
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
{
Expand Down Expand Up @@ -481,6 +488,8 @@ export const SETTINGS_SCHEMA = {

modelRoles: { type: "record", default: EMPTY_STRING_RECORD },

profiles: { type: "record", default: EMPTY_PROFILES_RECORD },

modelTags: { type: "record", default: EMPTY_MODEL_TAGS_RECORD },

modelProviderOrder: { type: "array", default: EMPTY_STRING_ARRAY },
Expand Down Expand Up @@ -5254,6 +5263,7 @@ export interface GroupTypeMap {
thinkingBudgets: ThinkingBudgetsSettings;
stt: SttSettings;
modelRoles: Record<string, string>;
profiles: Record<string, ProfileSnapshot>;
modelTags: ModelTagsSettings;
cycleOrder: string[];
shellMinimizer: ShellMinimizerSettings;
Expand Down
51 changes: 48 additions & 3 deletions packages/coding-agent/src/modes/components/model-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export interface ModelHubCallbacks {
onLoginRequest?: (providerId: string) => void;
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
onCycleOrderChange?: (order: string[]) => void;
/** Save the current model configuration as a named profile. */
onSaveProfile?: (name: string) => void;
onCancel: () => void;
}

Expand Down Expand Up @@ -144,6 +146,11 @@ type StripState =
/** Footer text input naming a new custom role. */
kind: "roleName";
input: Input;
}
| {
/** Footer text input naming a profile to save. */
kind: "profileName";
input: Input;
};

/** Recorded chip hit-range on the footer row (columns relative to frame col 0). */
Expand Down Expand Up @@ -925,7 +932,7 @@ export class ModelHubComponent implements Component {

#activateStripChip(): void {
const strip = this.#strip;
if (!strip || strip.kind === "roleName") return;
if (!strip || strip.kind === "roleName" || strip.kind === "profileName") return;
const chip = strip.chips[strip.index];
if (!chip) return;
switch (chip.action) {
Expand Down Expand Up @@ -1135,6 +1142,22 @@ export class ModelHubComponent implements Component {
this.#startAssign(name);
}

/** Open the footer name input that saves the current config as a profile. */
#openProfileNameStrip(): void {
this.#strip = { kind: "profileName", input: new Input() };
}

/** Validate and commit the profile name: save current model config. */
#submitProfileName(): void {
const strip = this.#strip;
if (strip?.kind !== "profileName") return;
const name = strip.input.getValue().trim();
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
this.#strip = null;
this.#chipRanges = [];
this.#callbacks.onSaveProfile?.(name);
}

// ═══════════════════════════════════════════════════════════════════════
// Input
// ═══════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -1239,6 +1262,14 @@ export class ModelHubComponent implements Component {
strip.input.handleInput(data);
return;
}
if (strip.kind === "profileName") {
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#submitProfileName();
return;
}
strip.input.handleInput(data);
return;
}
if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab")) {
strip.index = (strip.index - 1 + strip.chips.length) % strip.chips.length;
return;
Expand Down Expand Up @@ -1391,6 +1422,10 @@ export class ModelHubComponent implements Component {
this.#openRoleNameStrip();
return;
}
if (matchesKey(data, "ctrl+s")) {
this.#openProfileNameStrip();
return;
}
if (printable === "t") {
const assignment = role ? this.#roles[role] : undefined;
if (role && assignment) {
Expand Down Expand Up @@ -1431,7 +1466,7 @@ export class ModelHubComponent implements Component {
// Footer strip chips.
if (event.row === this.#footerRow && this.#strip) {
const strip = this.#strip;
if (event.leftClick && strip.kind !== "roleName") {
if (event.leftClick && strip.kind !== "roleName" && strip.kind !== "profileName") {
for (const range of this.#chipRanges) {
if (event.col >= range.start && event.col < range.end) {
strip.index = range.index;
Expand Down Expand Up @@ -1854,6 +1889,9 @@ export class ModelHubComponent implements Component {
if (strip.kind === "roleName") {
return "Enter create + pick model · Esc cancel";
}
if (strip.kind === "profileName") {
return "Enter save · Esc cancel";
}
return strip.kind === "role"
? "←/→ choose · Enter assign/clear · Esc cancel"
: "←/→ thinking level · Enter apply · Esc keep";
Expand Down Expand Up @@ -1883,7 +1921,7 @@ export class ModelHubComponent implements Component {
if (row?.kind === "newFallback") {
return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
}
return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new · ctrl+s save profile";
}
if (entry.kind === "provider" && entry.locked) {
return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
Expand Down Expand Up @@ -1911,6 +1949,13 @@ export class ModelHubComponent implements Component {
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
}

if (strip.kind === "profileName") {
const label = theme.fg("accent", "Profile name:");
const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("Profile name:") - 24));
const inputLine = strip.input.render(inputWidth)[0] ?? "";
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
}

const prefix =
strip.kind === "role"
? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
Expand Down
56 changes: 56 additions & 0 deletions packages/coding-agent/src/modes/components/profile-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../theme/theme";
import { DynamicBorder } from "./dynamic-border";
import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing";

export interface ProfileSelectorCallbacks {
onPick: (profileName: string) => void;
onCancel: () => void;
}

/**
* Interactive profile picker: lists saved profiles with keyboard navigation
* and fuzzy search. Rendered as an editor-slot selector (like /thinking).
*/
export class ProfileSelectorComponent extends Container {
#selectList: SelectList;

constructor(profileNames: string[], callbacks: ProfileSelectorCallbacks) {
super();

const items: SelectItem[] = profileNames.map(name => ({
value: name,
label: name,
}));

this.addChild(new DynamicBorder());

this.#selectList = new SelectList(
items.length > 0
? items
: [{ value: "", label: "No profiles saved. Use /profiles save <name> to create one." }],
Math.min(Math.max(items.length, 1), 15),
getSelectListTheme(),
);

this.#selectList.onSelect = item => {
if (item.value) callbacks.onPick(item.value);
};

this.#selectList.onCancel = () => {
callbacks.onCancel();
};

this.addChild(this.#selectList);

this.addChild(new DynamicBorder());
}

routeMouse(event: SgrMouseEvent, line: number, col: number): void {
routeSelectListMouseWithTopBorder(this.#selectList, event, line, col);
}

getSelectList(): SelectList {
return this.#selectList;
}
}
46 changes: 46 additions & 0 deletions packages/coding-agent/src/modes/controllers/selector-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "../../advisor";
import { formatModelSelectorValue, resolveAdvisorRoleSelection } from "../../config/model-resolver";
import { getRoleInfo } from "../../config/model-roles";
import { getProfileNames, saveProfile, switchProfileAndResolve } from "../../config/profiles";
import { settings } from "../../config/settings";
import { disableProvider, enableProvider } from "../../discovery";
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
Expand Down Expand Up @@ -70,6 +71,7 @@ import { LogoutAccountSelectorComponent } from "../components/logout-account-sel
import { ModelHubComponent, type ModelHubMode } from "../components/model-hub";
import { OAuthSelectorComponent } from "../components/oauth-selector";
import { PluginSelectorComponent } from "../components/plugin-selector";
import { ProfileSelectorComponent } from "../components/profile-selector";
import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
import { SessionSelectorComponent } from "../components/session-selector";
import { SettingsSelectorComponent } from "../components/settings-selector";
Expand Down Expand Up @@ -715,6 +717,14 @@ export class SelectorController {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
onSaveProfile: name => {
try {
saveProfile(this.ctx.settings, name);
this.ctx.showStatus(`Profile saved: ${name}`);
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
onCancel: () => done(),
},
{
Expand Down Expand Up @@ -1458,6 +1468,42 @@ export class SelectorController {
});
}

showProfileSelector(): void {
const names = getProfileNames(this.ctx.settings);
this.showSelector(done => {
const selector = new ProfileSelectorComponent(names, {
onPick: name => {
done();
const availableModels = this.ctx.session.getAvailableModels?.() ?? [];
const result = switchProfileAndResolve(this.ctx.settings, name, availableModels);
if (!result.ok) {
this.ctx.showError(`Profile not found: ${name}`);
return;
}
if (result.model) {
try {
void this.ctx.session.setModel(result.model);
if (result.thinkingLevel && result.thinkingLevel !== ThinkingLevel.Inherit) {
this.ctx.session.setThinkingLevel(result.thinkingLevel);
Comment thread
oldschoola marked this conversation as resolved.
Outdated
}
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
} catch {
// Settings applied, model switch failed
}
}
this.ctx.statusLine.invalidate();
this.ctx.showStatus(`Switched to profile: ${name}`);
},
onCancel: () => {
done();
this.ctx.ui.requestRender();
},
});
return { component: selector, focus: selector.getSelectList() };
});
}

showAgentHub(observers: SessionObserverRegistry, options?: { requireContent?: boolean }): void {
const hubKeys = [
...this.ctx.keybindings.getKeys("app.agents.hub"),
Expand Down
Loading
Loading