Add /models command for enabling/disabling Ctrl+P model cycling

- New /models command with toggle UI for each available model
- Changes persist to enabledModels in settings.json
- Updates take effect immediately for Ctrl+P cycling
This commit is contained in:
Carlos Gutierrez 2026-01-10 22:50:28 -07:00 committed by Mario Zechner
parent 42ed0129ed
commit 49acd8e648
7 changed files with 241 additions and 0 deletions

View file

@ -4,6 +4,7 @@
### Added
- `/models` command to enable/disable models for Ctrl+P cycling. Changes persist to `enabledModels` in settings.json and take effect immediately. ([#626](https://github.com/badlogic/pi-mono/pull/626) by [@CarlosGtrz](https://github.com/CarlosGtrz))
- `model_select` extension hook fires when model changes via `/model`, model cycling, or session restore with `source` field and `previousModel` ([#628](https://github.com/badlogic/pi-mono/pull/628) by [@marckrenn](https://github.com/marckrenn))
- `ctx.ui.setWorkingMessage()` extension API to customize the "Working..." message during streaming ([#625](https://github.com/badlogic/pi-mono/pull/625) by [@nicobailon](https://github.com/nicobailon))
- Skill slash commands: loaded skills are registered as `/skill:name` commands for quick access. Toggle via `/settings` or `skills.enableSkillCommands` in settings.json. ([#630](https://github.com/badlogic/pi-mono/pull/630) by [@Dwsy](https://github.com/Dwsy))

View file

@ -236,6 +236,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
|---------|-------------|
| `/settings` | Open settings menu (thinking, theme, message delivery modes, toggles) |
| `/model` | Switch models mid-session. Use `/model <search>` or `provider/model` to prefilter/disambiguate. |
| `/models` | Enable/disable models for Ctrl+P cycling |
| `/export [file]` | Export session to self-contained HTML |
| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
| `/session` | Show session info: path, message counts, token usage, cost |

View file

@ -525,6 +525,11 @@ export class AgentSession {
return this._scopedModels;
}
/** Update scoped models for cycling */
setScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>): void {
this._scopedModels = scopedModels;
}
/** File-based prompt templates */
get promptTemplates(): ReadonlyArray<PromptTemplate> {
return this._promptTemplates;

View file

@ -447,6 +447,11 @@ export class SettingsManager {
return this.settings.enabledModels;
}
setEnabledModels(patterns: string[] | undefined): void {
this.globalSettings.enabledModels = patterns;
this.save();
}
getDoubleEscapeAction(): "branch" | "tree" {
return this.settings.doubleEscapeAction ?? "tree";
}

View file

@ -15,6 +15,7 @@ export { ExtensionSelectorComponent } from "./extension-selector.js";
export { FooterComponent } from "./footer.js";
export { LoginDialogComponent } from "./login-dialog.js";
export { ModelSelectorComponent } from "./model-selector.js";
export { type ModelsCallbacks, type ModelsConfig, ModelsSelectorComponent } from "./models-selector.js";
export { OAuthSelectorComponent } from "./oauth-selector.js";
export { SessionSelectorComponent } from "./session-selector.js";
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";

View file

@ -0,0 +1,146 @@
import type { Model } from "@mariozechner/pi-ai";
import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface ModelItem {
fullId: string;
model: Model<any>;
enabled: boolean;
}
export interface ModelsConfig {
allModels: Model<any>[];
enabledModelIds: Set<string>;
/** true if enabledModels setting is defined (empty = all enabled) */
hasEnabledModelsFilter: boolean;
}
export interface ModelsCallbacks {
onModelToggle: (modelId: string, enabled: boolean) => void;
onCancel: () => void;
}
/**
* Component for enabling/disabling models for Ctrl+P cycling.
*/
export class ModelsSelectorComponent extends Container {
private items: ModelItem[] = [];
private selectedIndex = 0;
private listContainer: Container;
private callbacks: ModelsCallbacks;
private maxVisible = 15;
constructor(config: ModelsConfig, callbacks: ModelsCallbacks) {
super();
this.callbacks = callbacks;
// Group models by provider for organized display
const modelsByProvider = new Map<string, Model<any>[]>();
for (const model of config.allModels) {
const list = modelsByProvider.get(model.provider) ?? [];
list.push(model);
modelsByProvider.set(model.provider, list);
}
// Build items - group by provider
for (const [provider, models] of modelsByProvider) {
for (const model of models) {
const fullId = `${provider}/${model.id}`;
// If no filter defined, all models are enabled by default
const isEnabled = !config.hasEnabledModelsFilter || config.enabledModelIds.has(fullId);
this.items.push({
fullId,
model,
enabled: isEnabled,
});
}
}
// Header
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold("Model Configuration")), 0, 0));
this.addChild(new Text(theme.fg("muted", "Enable/disable models for Ctrl+P cycling"), 0, 0));
this.addChild(new Spacer(1));
// List container
this.listContainer = new Container();
this.addChild(this.listContainer);
// Footer hint
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("dim", " Enter/Space to toggle · Esc to close"), 0, 0));
this.addChild(new DynamicBorder());
this.updateList();
}
private updateList(): void {
this.listContainer.clear();
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
for (let i = startIndex; i < endIndex; i++) {
const item = this.items[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
let line = "";
if (isSelected) {
const prefix = theme.fg("accent", "→ ");
const modelText = theme.fg("accent", item.model.id);
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
const status = item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
line = `${prefix}${modelText}${providerBadge}${status}`;
} else {
const prefix = " ";
const modelText = item.model.id;
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
const status = item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
line = `${prefix}${modelText}${providerBadge}${status}`;
}
this.listContainer.addChild(new Text(line, 0, 0));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.items.length) {
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.items.length})`);
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
}
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
if (kb.matches(data, "selectUp")) {
this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1;
this.updateList();
} else if (kb.matches(data, "selectDown")) {
this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
} else if (kb.matches(data, "selectConfirm") || data === " ") {
// Toggle on Enter or Space
const item = this.items[this.selectedIndex];
if (item) {
item.enabled = !item.enabled;
this.callbacks.onModelToggle(item.fullId, item.enabled);
this.updateList();
}
} else if (kb.matches(data, "selectCancel")) {
this.callbacks.onCancel();
}
}
getSettingsList(): this {
// Return self for focus management (compatible with showSelector interface)
return this;
}
}

View file

@ -45,6 +45,7 @@ import type {
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
import { KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { resolveModelScope } from "../../core/model-resolver.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { loadProjectContextFiles } from "../../core/system-prompt.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
@ -68,6 +69,7 @@ import { ExtensionSelectorComponent } from "./components/extension-selector.js";
import { FooterComponent } from "./components/footer.js";
import { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { ModelsSelectorComponent } from "./components/models-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js";
@ -280,6 +282,7 @@ export class InteractiveMode {
}));
},
},
{ name: "models", description: "Enable/disable models for Ctrl+P cycling" },
{ name: "export", description: "Export session to HTML file" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "copy", description: "Copy last agent message to clipboard" },
@ -1412,6 +1415,11 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/models") {
this.editor.setText("");
await this.showModelsSelector();
return;
}
if (text === "/model" || text.startsWith("/model ")) {
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
this.editor.setText("");
@ -2655,6 +2663,80 @@ export class InteractiveMode {
});
}
private async showModelsSelector(): Promise<void> {
// Get all available models
this.session.modelRegistry.refresh();
const allModels = this.session.modelRegistry.getAvailable();
if (allModels.length === 0) {
this.showStatus("No models available");
return;
}
// Get current enabledModels patterns
const patterns = this.settingsManager.getEnabledModels();
const hasFilter = patterns !== undefined && patterns.length > 0;
// Resolve patterns to get currently enabled model IDs
const enabledModelIds = new Set<string>();
if (hasFilter) {
const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
for (const sm of scopedModels) {
enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
}
}
// Track current enabled state
const currentEnabledIds = new Set(enabledModelIds);
let currentHasFilter = hasFilter;
this.showSelector((done) => {
const selector = new ModelsSelectorComponent(
{
allModels,
enabledModelIds: currentEnabledIds,
hasEnabledModelsFilter: currentHasFilter,
},
{
onModelToggle: async (modelId, enabled) => {
if (enabled) {
currentEnabledIds.add(modelId);
} else {
currentEnabledIds.delete(modelId);
}
currentHasFilter = true;
// Save to settings
const newPatterns =
currentEnabledIds.size === allModels.length
? undefined // All enabled = clear filter
: Array.from(currentEnabledIds);
this.settingsManager.setEnabledModels(newPatterns);
// Update session's scoped models
if (newPatterns && newPatterns.length > 0) {
const defaultThinkingLevel = this.settingsManager.getDefaultThinkingLevel() ?? "off";
const newScopedModels = await resolveModelScope(newPatterns, this.session.modelRegistry);
this.session.setScopedModels(
newScopedModels.map((sm) => ({
model: sm.model,
thinkingLevel: sm.thinkingLevel ?? defaultThinkingLevel,
})),
);
} else {
this.session.setScopedModels([]);
}
},
onCancel: () => {
done();
this.ui.requestRender();
},
},
);
return { component: selector, focus: selector.getSettingsList() };
});
}
private showUserMessageSelector(): void {
const userMessages = this.session.getUserMessagesForBranching();