mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
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:
parent
42ed0129ed
commit
49acd8e648
7 changed files with 241 additions and 0 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue