feat: add model profiles for saving and switching model configurations#5253
feat: add model profiles for saving and switching model configurations#5253oldschoola wants to merge 10 commits into
Conversation
- Add /profiles slash command with save, switch, delete, list subcommands
- Add 's' keybinding in /models roles view to save current config as a
named profile via a footer name input strip
- Profiles capture modelRoles (all role → model selector assignments)
and defaultThinkingLevel as a ProfileSnapshot
- switchProfile uses clean cutover: settings.set('modelRoles', snapshot)
replaces the entire record, not a per-role merge — stale assignments
from the previous profile are removed
- Profile switch resolves the default role's model via
resolveModelRoleValue (handles multi-slash IDs, thinking suffixes)
- Add profiles record to settings schema with ProfileSnapshot type
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf1aab6d5e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
- Apply resolved thinking level to session on profile switch (not just settings) so the next turn uses the profile's reasoning level - Clone the profiles record before mutating in saveProfile/deleteProfile to avoid mutating the shared schema default object
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1eb7b6e692
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const profiles = getProfiles(settings); | ||
| const profile = profiles[name]; | ||
| if (!profile) return false; | ||
| settings.set("modelRoles", profile.modelRoles); |
There was a problem hiding this comment.
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 👍 / 👎.
The 's' key conflicted with search input in the model hub. Use ctrl+s instead so typing in the search bar doesn't trigger the save dialog.
- /profiles with no args now opens an interactive picker (SelectList) to browse and select a profile to switch to - Extract switchProfileAndResolve() as the single source of truth for profile switching — eliminates duplication across ACP handler, TUI handler, and the new interactive picker - Add showProfileSelector to InteractiveModeContext and implement in selector-controller using the showSelector editor-slot pattern
Container doesn't forward input to child components, so Escape/cancel was never reaching the SelectList. Add handleInput delegation so the picker can be dismissed when empty.
Container doesn't forward handleInput to child components, so arrows, Enter, and Escape never reached the SelectList. Return getSelectList() as the focus target, matching the thinking/theme/plugin selector pattern.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7d628c826
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…picker - switchProfile now clears runtime modelRoles overrides (from --model or env) so the profile's assignments take effect instead of the override winning - showProfileSelector picker now awaits setModel before applying thinking level, preventing the async model switch from overwriting the profile's thinking level with the model's default
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d18f5d707
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
getProfiles() reads the merged view (global + project + overrides), but settings.set writes to global. saveProfile and deleteProfile now read from the global layer only via settings.getGlobalProfiles(), preventing project-scoped profiles from leaking into the global config when saving in a project context.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 474592b41d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…fault When a profile has no modelRoles.default (saved while default was automatic), switchProfileAndResolve now signals needsReset. The callers reset the session to its automatic/default model instead of leaving the previous profile's model active. Added AgentSession.resetToDefaultModel() which mirrors startup resolution: filters available models to those with configured auth, then picks via pickDefaultAvailableModel.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f361e3d77
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
When a profile has a non-empty modelRoles.default that doesn't resolve against available models (provider disabled, credentials removed, typo), switchProfileAndResolve now signals needsReset + unresolvedDefault. Callers warn the user and fall back to automatic default selection instead of silently keeping the previous model.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4056bac275
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
roboomp
left a comment
There was a problem hiding this comment.
review:p2 — coherent user-visible feature, but the current switch path has blocking contract failures.
Blocking: effective roles are deep-merged rather than replaced; saved default thinking is not applied live; inherited profile keys can wipe roles; live transition failures are reported as success.
Maintainer call: confirm whether selecting a profile must override project/config role layers; the advertised clean cutover requires an exact effective-layer policy.
Thanks @oldschoola for the focused feature and regression groundwork.
| const profiles = getProfiles(settings); | ||
| const profile = profiles[name]; | ||
| if (!profile) return false; | ||
| settings.set("modelRoles", profile.modelRoles); |
There was a problem hiding this comment.
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.
| return { | ||
| ok: true, | ||
| model: resolved.model, | ||
| thinkingLevel: resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined, |
There was a problem hiding this comment.
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.
| */ | ||
| export function switchProfile(settings: Settings, name: string): boolean { | ||
| const profiles = getProfiles(settings); | ||
| const profile = profiles[name]; |
There was a problem hiding this comment.
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.
| runtime.session.setThinkingLevel(result.thinkingLevel); | ||
| } | ||
| } catch { | ||
| // Settings applied, model switch failed | ||
| } | ||
| } | ||
| if (result.needsReset) { | ||
| try { | ||
| await runtime.session.resetToDefaultModel?.(); | ||
| } catch { | ||
| // Non-critical — settings are still applied | ||
| } |
There was a problem hiding this comment.
blocking: Both live-transition failures are swallowed, after which this handler prints Switched to profile and emits config/title changes. If setModel() rejects (auth/metadata/sync failure), or automatic reset cannot find an authenticated model, persisted roles change while the session keeps its old model and the command claims success. The TUI branch (:525, :534) and interactive picker (selector-controller.ts:1495, :1504) repeat the same false-success path. Surface a failed outcome and do not report a completed switch unless the live transition succeeded.
| const resolved = resolveModelRoleValue(defaultRole, availableModels as Model[], { | ||
| settings, | ||
| }); | ||
| if (!resolved.model) { |
There was a problem hiding this comment.
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.
Summary
Add the ability to save and switch between named "profiles" that capture the current model configuration (model selections per role + thinking level).
Changes
New files
src/config/profiles.ts—saveProfile,switchProfile,deleteProfile,getProfiles,getProfileNameshelpers. Pure settings operations, no session dependency.Modified files
src/config/settings-schema.ts—ProfileSnapshotinterface (modelRoles+defaultThinkingLevel),profilesrecord schema entry,GroupTypeMapentrysrc/slash-commands/builtin-registry.ts—/profilesslash command withsave,switch,delete,listsubcommands (both ACPhandleand TUIhandleTui); usesresolveModelRoleValuefor model selector resolution on switchsrc/modes/components/model-hub.ts—onSaveProfilecallback,profileNamestrip variant,skeybinding in roles view to save current config as a named profilesrc/modes/controllers/selector-controller.ts— wiresonSaveProfilecallback tosaveProfile()CHANGELOG.md— entry under[Unreleased]→AddedUsage
/profilesslash command/modelsroles viewPress
sin the roles view to open a footer name input — type a name and press Enter to save the current model configuration as a profile.Key design decisions
switchProfileusessettings.set("modelRoles", profile.modelRoles)to replace the ENTIRE record — not a loop ofsetModelRole()per entry. This ensures stale role assignments from the previously-active profile are removed.resolveModelRoleValue()frommodel-resolver.ts— not manualsplit("/")/split(":")— to handle multi-slash provider IDs (e.g. OpenRouter passthrough) and thinking-level suffixes correctly.modelRoles(all role → selector assignments) +defaultThinkingLevel. These together represent "what model is selected for each role."profilessetting has nouimetadata — managed via the slash command and model hub keybinding, same pattern asmodelRoles.Verification
bun run check:types— PASSTest plan
cd packages/coding-agent && bun run check:types/models, presss, type a profile name, Enter — verify "Profile saved" status/profiles list— verify the profile appears/profiles switch <name>— verify all roles revert/profiles delete <name>— verify it disappears from list