Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
112 changes: 112 additions & 0 deletions packages/coding-agent/src/config/profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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,
};
// Read from the global layer only — not the merged view — so we don't
// copy project-scoped profiles into the global config.
const profiles = { ...settings.getGlobalProfiles() };
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);
}
// Clear any runtime model-role overrides (from --model or env) so the
// profile's assignments take effect instead of the override winning.
settings.clearOverride("modelRoles");
return true;
}

/** Delete a named profile. Returns true if it existed. */
export function deleteProfile(settings: Settings, name: string): boolean {
// Read from the global layer only — not the merged view — so we don't
// copy project-scoped profiles into the global config.
const profiles = { ...settings.getGlobalProfiles() };
if (!(name in profiles)) return false;
delete profiles[name];
settings.set("profiles", profiles);
return true;
}

export interface ProfileSwitchResult {
ok: boolean;
model?: Model;
thinkingLevel?: ConfiguredThinkingLevel;
/** True when the profile has no explicit default role — the caller should
* reset the session to its automatic/default model. */
needsReset?: boolean;
/** True when the profile has a default role but it couldn't resolve against
* available models (provider disabled, credentials removed, typo). The
* caller should warn the user and reset to the automatic default. */
unresolvedDefault?: boolean;
}

/**
* 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, needsReset: true };
const resolved = resolveModelRoleValue(defaultRole, availableModels as Model[], {
settings,
});
if (!resolved.model) {

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.

should-fix: This assertion discards the ReadonlyArray contract and violates the repository's no-as rule. resolveModelRoleValue() does not mutate this collection, so its parameter should accept readonly Model[]; alternatively keep this API mutable to match the existing resolver instead of asserting away the mismatch.

// Profile has a default role but it doesn't resolve — fall back to
// automatic default and warn the user.
return { ok: true, needsReset: true, unresolvedDefault: true };
}
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
12 changes: 12 additions & 0 deletions packages/coding-agent/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type GroupPrefix,
type GroupTypeMap,
getDefault,
type ProfileSnapshot,
SETTINGS_SCHEMA,
type SettingPath,
type SettingValue,
Expand Down Expand Up @@ -661,6 +662,17 @@ export class Settings {
return normalized;
}

/**
* Get profiles from the global/persisted layer only (not merged with project
* or runtime overrides). Used by saveProfile/deleteProfile to avoid copying
* project-scoped profiles into the global config.
*/
getGlobalProfiles(): Record<string, ProfileSnapshot> {
const value = getByPath(this.#global, ["profiles"]);
if (!isRecord(value)) return {};
return value as Record<string, ProfileSnapshot>;
}

/*
* Override model roles (helper for modelRoles record).
*/
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;
}
}
Loading
Loading