From 49acd8e648ab845f5665b6ecf5b91ad5d6dadb7e Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Sat, 10 Jan 2026 22:50:28 -0700 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 1 + .../coding-agent/src/core/agent-session.ts | 5 + .../coding-agent/src/core/settings-manager.ts | 5 + .../src/modes/interactive/components/index.ts | 1 + .../interactive/components/models-selector.ts | 146 ++++++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 82 ++++++++++ 7 files changed, 241 insertions(+) create mode 100644 packages/coding-agent/src/modes/interactive/components/models-selector.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4ef89e8c..b612023c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 8116a2c1..2a022863 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 ` 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 | diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 6776cc4c..6969c9ca 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -525,6 +525,11 @@ export class AgentSession { return this._scopedModels; } + /** Update scoped models for cycling */ + setScopedModels(scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>): void { + this._scopedModels = scopedModels; + } + /** File-based prompt templates */ get promptTemplates(): ReadonlyArray { return this._promptTemplates; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index f21d6319..7166a012 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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"; } diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 9ae7f7c3..47502c73 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/models-selector.ts b/packages/coding-agent/src/modes/interactive/components/models-selector.ts new file mode 100644 index 00000000..dcc8ed23 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/models-selector.ts @@ -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; + enabled: boolean; +} + +export interface ModelsConfig { + allModels: Model[]; + enabledModelIds: Set; + /** 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[]>(); + 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; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9efd3c39..cd9e49c2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { + // 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(); + 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();