mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 18:05:11 +00:00
Rework /scoped-models command UX
- Renamed from /models to /scoped-models - Changes are now session-only by default (Ctrl+S to persist to settings) - Added search with fuzzy filtering - Hotkeys: Enter toggle, Ctrl+A enable all, Ctrl+X clear all, Ctrl+P toggle provider - Ctrl+C clears search (or exits if empty), Escape exits - Enabled models shown at top of list - No checkmarks when all models enabled (no scope) - First toggle when unscoped clears all and selects that model - Uses current session thinking level instead of settings default - Reads from session state first (preserves session-only changes across /scoped-models invocations) Builds on #626
This commit is contained in:
parent
4755da4bfb
commit
50cd054edb
6 changed files with 392 additions and 182 deletions
|
|
@ -18,7 +18,7 @@
|
||||||
- `SessionManager.list()` and `SessionManager.listAll()` accept optional `onProgress` callback for progress updates
|
- `SessionManager.list()` and `SessionManager.listAll()` accept optional `onProgress` callback for progress updates
|
||||||
- `SessionInfo.cwd` field containing the session's working directory (empty string for old sessions)
|
- `SessionInfo.cwd` field containing the session's working directory (empty string for old sessions)
|
||||||
- `SessionListProgress` type export for progress callbacks
|
- `SessionListProgress` type export for progress callbacks
|
||||||
- `/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))
|
- `/scoped-models` command to enable/disable models for Ctrl+P cycling. Changes are session-only by default; press Ctrl+S to persist to settings.json. ([#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))
|
- `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))
|
- `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))
|
- 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,7 +236,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/settings` | Open settings menu (thinking, theme, message delivery modes, toggles) |
|
| `/settings` | Open settings menu (thinking, theme, message delivery modes, toggles) |
|
||||||
| `/model` | Switch models mid-session. Use `/model <search>` or `provider/model` to prefilter/disambiguate. |
|
| `/model` | Switch models mid-session. Use `/model <search>` or `provider/model` to prefilter/disambiguate. |
|
||||||
| `/models` | Enable/disable models for Ctrl+P cycling |
|
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
|
||||||
| `/export [file]` | Export session to self-contained HTML |
|
| `/export [file]` | Export session to self-contained HTML |
|
||||||
| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
|
| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
|
||||||
| `/session` | Show session info: path, message counts, token usage, cost |
|
| `/session` | Show session info: path, message counts, token usage, cost |
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export { ExtensionSelectorComponent } from "./extension-selector.js";
|
||||||
export { FooterComponent } from "./footer.js";
|
export { FooterComponent } from "./footer.js";
|
||||||
export { LoginDialogComponent } from "./login-dialog.js";
|
export { LoginDialogComponent } from "./login-dialog.js";
|
||||||
export { ModelSelectorComponent } from "./model-selector.js";
|
export { ModelSelectorComponent } from "./model-selector.js";
|
||||||
export { type ModelsCallbacks, type ModelsConfig, ModelsSelectorComponent } from "./models-selector.js";
|
|
||||||
export { OAuthSelectorComponent } from "./oauth-selector.js";
|
export { OAuthSelectorComponent } from "./oauth-selector.js";
|
||||||
|
export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.js";
|
||||||
export { SessionSelectorComponent } from "./session-selector.js";
|
export { SessionSelectorComponent } from "./session-selector.js";
|
||||||
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";
|
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";
|
||||||
export { ShowImagesSelectorComponent } from "./show-images-selector.js";
|
export { ShowImagesSelectorComponent } from "./show-images-selector.js";
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
import type { Model } from "@mariozechner/pi-ai";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
fuzzyFilter,
|
||||||
|
getEditorKeybindings,
|
||||||
|
Input,
|
||||||
|
Key,
|
||||||
|
matchesKey,
|
||||||
|
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 {
|
||||||
|
/** Called when a model is toggled (session-only, no persist) */
|
||||||
|
onModelToggle: (modelId: string, enabled: boolean) => void;
|
||||||
|
/** Called when user wants to persist current selection to settings */
|
||||||
|
onPersist: (enabledModelIds: string[]) => void;
|
||||||
|
/** Called when user enables all models. Returns list of all model IDs. */
|
||||||
|
onEnableAll: (allModelIds: string[]) => void;
|
||||||
|
/** Called when user clears all models */
|
||||||
|
onClearAll: () => void;
|
||||||
|
/** Called when user toggles all models for a provider. Returns affected model IDs. */
|
||||||
|
onToggleProvider: (provider: string, modelIds: string[], enabled: boolean) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for enabling/disabling models for Ctrl+P cycling.
|
||||||
|
* Changes are session-only until explicitly persisted with Ctrl+S.
|
||||||
|
*/
|
||||||
|
export class ScopedModelsSelectorComponent extends Container {
|
||||||
|
private items: ModelItem[] = [];
|
||||||
|
private filteredItems: ModelItem[] = [];
|
||||||
|
private selectedIndex = 0;
|
||||||
|
private searchInput: Input;
|
||||||
|
private listContainer: Container;
|
||||||
|
private footerText: Text;
|
||||||
|
private callbacks: ModelsCallbacks;
|
||||||
|
private maxVisible = 15;
|
||||||
|
private isDirty = false;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.filteredItems = this.getSortedItems();
|
||||||
|
|
||||||
|
// 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", "Session-only. Ctrl+S to save to settings."), 0, 0));
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
this.searchInput = new Input();
|
||||||
|
this.addChild(this.searchInput);
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
// List container
|
||||||
|
this.listContainer = new Container();
|
||||||
|
this.addChild(this.listContainer);
|
||||||
|
|
||||||
|
// Footer hint
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.footerText = new Text(this.getFooterText(), 0, 0);
|
||||||
|
this.addChild(this.footerText);
|
||||||
|
|
||||||
|
this.addChild(new DynamicBorder());
|
||||||
|
|
||||||
|
this.updateList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get items sorted with enabled items first */
|
||||||
|
private getSortedItems(): ModelItem[] {
|
||||||
|
const enabled = this.items.filter((i) => i.enabled);
|
||||||
|
const disabled = this.items.filter((i) => !i.enabled);
|
||||||
|
return [...enabled, ...disabled];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFooterText(): string {
|
||||||
|
const enabledCount = this.items.filter((i) => i.enabled).length;
|
||||||
|
const allEnabled = enabledCount === this.items.length;
|
||||||
|
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.items.length} enabled`;
|
||||||
|
const parts = ["Enter toggle", "^A all", "^X clear", "^P provider", "^S save", countText];
|
||||||
|
if (this.isDirty) {
|
||||||
|
return theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)");
|
||||||
|
}
|
||||||
|
return theme.fg("dim", ` ${parts.join(" · ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFooter(): void {
|
||||||
|
this.footerText.setText(this.getFooterText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterItems(query: string): void {
|
||||||
|
const sorted = this.getSortedItems();
|
||||||
|
if (!query) {
|
||||||
|
this.filteredItems = sorted;
|
||||||
|
} else {
|
||||||
|
this.filteredItems = fuzzyFilter(sorted, query, (item) => `${item.model.id} ${item.model.provider}`);
|
||||||
|
}
|
||||||
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||||
|
this.updateList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateList(): void {
|
||||||
|
this.listContainer.clear();
|
||||||
|
|
||||||
|
if (this.filteredItems.length === 0) {
|
||||||
|
this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||||
|
);
|
||||||
|
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
||||||
|
|
||||||
|
// Only show status if there's a filter (not all models enabled)
|
||||||
|
const allEnabled = this.items.every((i) => i.enabled);
|
||||||
|
|
||||||
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
|
const item = this.filteredItems[i];
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const isSelected = i === this.selectedIndex;
|
||||||
|
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||||
|
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
|
||||||
|
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
|
||||||
|
// Only show checkmarks when there's actually a filter
|
||||||
|
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
|
||||||
|
|
||||||
|
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add scroll indicator if needed
|
||||||
|
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
||||||
|
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`);
|
||||||
|
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleItem(item: ModelItem): void {
|
||||||
|
// If all models are currently enabled (no scope yet), first toggle starts fresh:
|
||||||
|
// clear all and enable only the selected model
|
||||||
|
const allEnabled = this.items.every((i) => i.enabled);
|
||||||
|
if (allEnabled) {
|
||||||
|
for (const i of this.items) {
|
||||||
|
i.enabled = false;
|
||||||
|
}
|
||||||
|
item.enabled = true;
|
||||||
|
this.isDirty = true;
|
||||||
|
this.callbacks.onClearAll();
|
||||||
|
this.callbacks.onModelToggle(item.fullId, true);
|
||||||
|
} else {
|
||||||
|
item.enabled = !item.enabled;
|
||||||
|
this.isDirty = true;
|
||||||
|
this.callbacks.onModelToggle(item.fullId, item.enabled);
|
||||||
|
}
|
||||||
|
// Re-sort and re-filter to move item to correct section
|
||||||
|
this.filterItems(this.searchInput.getValue());
|
||||||
|
this.updateFooter();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
const kb = getEditorKeybindings();
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
if (kb.matches(data, "selectUp")) {
|
||||||
|
if (this.filteredItems.length === 0) return;
|
||||||
|
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
|
||||||
|
this.updateList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kb.matches(data, "selectDown")) {
|
||||||
|
if (this.filteredItems.length === 0) return;
|
||||||
|
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
|
||||||
|
this.updateList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle on Enter
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
|
if (item) {
|
||||||
|
this.toggleItem(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+A - Enable all (filtered if search active, otherwise all)
|
||||||
|
if (matchesKey(data, Key.ctrl("a"))) {
|
||||||
|
const targets = this.searchInput.getValue() ? this.filteredItems : this.items;
|
||||||
|
for (const item of targets) {
|
||||||
|
item.enabled = true;
|
||||||
|
}
|
||||||
|
this.isDirty = true;
|
||||||
|
this.callbacks.onEnableAll(targets.map((i) => i.fullId));
|
||||||
|
this.filterItems(this.searchInput.getValue());
|
||||||
|
this.updateFooter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+X - Clear all (filtered if search active, otherwise all)
|
||||||
|
if (matchesKey(data, Key.ctrl("x"))) {
|
||||||
|
const targets = this.searchInput.getValue() ? this.filteredItems : this.items;
|
||||||
|
for (const item of targets) {
|
||||||
|
item.enabled = false;
|
||||||
|
}
|
||||||
|
this.isDirty = true;
|
||||||
|
this.callbacks.onClearAll();
|
||||||
|
this.filterItems(this.searchInput.getValue());
|
||||||
|
this.updateFooter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+P - Toggle provider of current item
|
||||||
|
if (matchesKey(data, Key.ctrl("p"))) {
|
||||||
|
const currentItem = this.filteredItems[this.selectedIndex];
|
||||||
|
if (currentItem) {
|
||||||
|
const provider = currentItem.model.provider;
|
||||||
|
const providerItems = this.items.filter((i) => i.model.provider === provider);
|
||||||
|
const allEnabled = providerItems.every((i) => i.enabled);
|
||||||
|
const newState = !allEnabled;
|
||||||
|
for (const item of providerItems) {
|
||||||
|
item.enabled = newState;
|
||||||
|
}
|
||||||
|
this.isDirty = true;
|
||||||
|
this.callbacks.onToggleProvider(
|
||||||
|
provider,
|
||||||
|
providerItems.map((i) => i.fullId),
|
||||||
|
newState,
|
||||||
|
);
|
||||||
|
this.filterItems(this.searchInput.getValue());
|
||||||
|
this.updateFooter();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+S - Save/persist to settings
|
||||||
|
if (matchesKey(data, Key.ctrl("s"))) {
|
||||||
|
const enabledIds = this.items.filter((i) => i.enabled).map((i) => i.fullId);
|
||||||
|
this.callbacks.onPersist(enabledIds);
|
||||||
|
this.isDirty = false;
|
||||||
|
this.updateFooter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C - clear search or cancel if empty
|
||||||
|
if (matchesKey(data, Key.ctrl("c"))) {
|
||||||
|
if (this.searchInput.getValue()) {
|
||||||
|
this.searchInput.setValue("");
|
||||||
|
this.filterItems("");
|
||||||
|
} else {
|
||||||
|
this.callbacks.onCancel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape - cancel
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
this.callbacks.onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass everything else to search input
|
||||||
|
this.searchInput.handleInput(data);
|
||||||
|
this.filterItems(this.searchInput.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
getSearchInput(): Input {
|
||||||
|
return this.searchInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,8 +69,8 @@ import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
||||||
import { FooterComponent } from "./components/footer.js";
|
import { FooterComponent } from "./components/footer.js";
|
||||||
import { LoginDialogComponent } from "./components/login-dialog.js";
|
import { LoginDialogComponent } from "./components/login-dialog.js";
|
||||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||||
import { ModelsSelectorComponent } from "./components/models-selector.js";
|
|
||||||
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
||||||
|
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
|
||||||
import { SessionSelectorComponent } from "./components/session-selector.js";
|
import { SessionSelectorComponent } from "./components/session-selector.js";
|
||||||
import { SettingsSelectorComponent } from "./components/settings-selector.js";
|
import { SettingsSelectorComponent } from "./components/settings-selector.js";
|
||||||
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
||||||
|
|
@ -282,7 +282,7 @@ export class InteractiveMode {
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ name: "models", description: "Enable/disable models for Ctrl+P cycling" },
|
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
|
||||||
{ name: "export", description: "Export session to HTML file" },
|
{ name: "export", description: "Export session to HTML file" },
|
||||||
{ name: "share", description: "Share session as a secret GitHub gist" },
|
{ name: "share", description: "Share session as a secret GitHub gist" },
|
||||||
{ name: "copy", description: "Copy last agent message to clipboard" },
|
{ name: "copy", description: "Copy last agent message to clipboard" },
|
||||||
|
|
@ -1415,7 +1415,7 @@ export class InteractiveMode {
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text === "/models") {
|
if (text === "/scoped-models") {
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
await this.showModelsSelector();
|
await this.showModelsSelector();
|
||||||
return;
|
return;
|
||||||
|
|
@ -2674,25 +2674,56 @@ export class InteractiveMode {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current enabledModels patterns
|
// Check if session has scoped models (from previous session-only changes or CLI --models)
|
||||||
const patterns = this.settingsManager.getEnabledModels();
|
const sessionScopedModels = this.session.scopedModels;
|
||||||
const hasFilter = patterns !== undefined && patterns.length > 0;
|
const hasSessionScope = sessionScopedModels.length > 0;
|
||||||
|
|
||||||
// Resolve patterns to get currently enabled model IDs
|
// Build enabled model IDs from session state or settings
|
||||||
const enabledModelIds = new Set<string>();
|
const enabledModelIds = new Set<string>();
|
||||||
if (hasFilter) {
|
let hasFilter = false;
|
||||||
const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
|
|
||||||
for (const sm of scopedModels) {
|
if (hasSessionScope) {
|
||||||
|
// Use current session's scoped models
|
||||||
|
for (const sm of sessionScopedModels) {
|
||||||
enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
|
enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
|
||||||
}
|
}
|
||||||
|
hasFilter = true;
|
||||||
|
} else {
|
||||||
|
// Fall back to settings
|
||||||
|
const patterns = this.settingsManager.getEnabledModels();
|
||||||
|
if (patterns !== undefined && patterns.length > 0) {
|
||||||
|
hasFilter = true;
|
||||||
|
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
|
// Track current enabled state (session-only until persisted)
|
||||||
const currentEnabledIds = new Set(enabledModelIds);
|
const currentEnabledIds = new Set(enabledModelIds);
|
||||||
let currentHasFilter = hasFilter;
|
let currentHasFilter = hasFilter;
|
||||||
|
|
||||||
|
// Helper to update session's scoped models (session-only, no persist)
|
||||||
|
const updateSessionModels = async (enabledIds: Set<string>) => {
|
||||||
|
if (enabledIds.size > 0 && enabledIds.size < allModels.length) {
|
||||||
|
// Use current session thinking level, not settings default
|
||||||
|
const currentThinkingLevel = this.session.thinkingLevel;
|
||||||
|
const newScopedModels = await resolveModelScope(Array.from(enabledIds), this.session.modelRegistry);
|
||||||
|
this.session.setScopedModels(
|
||||||
|
newScopedModels.map((sm) => ({
|
||||||
|
model: sm.model,
|
||||||
|
thinkingLevel: sm.thinkingLevel ?? currentThinkingLevel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// All enabled or none enabled = no filter
|
||||||
|
this.session.setScopedModels([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.showSelector((done) => {
|
this.showSelector((done) => {
|
||||||
const selector = new ModelsSelectorComponent(
|
const selector = new ScopedModelsSelectorComponent(
|
||||||
{
|
{
|
||||||
allModels,
|
allModels,
|
||||||
enabledModelIds: currentEnabledIds,
|
enabledModelIds: currentEnabledIds,
|
||||||
|
|
@ -2706,27 +2737,40 @@ export class InteractiveMode {
|
||||||
currentEnabledIds.delete(modelId);
|
currentEnabledIds.delete(modelId);
|
||||||
}
|
}
|
||||||
currentHasFilter = true;
|
currentHasFilter = true;
|
||||||
|
await updateSessionModels(currentEnabledIds);
|
||||||
// Save to settings
|
},
|
||||||
const newPatterns =
|
onEnableAll: async (allModelIds) => {
|
||||||
currentEnabledIds.size === allModels.length
|
currentEnabledIds.clear();
|
||||||
? undefined // All enabled = clear filter
|
for (const id of allModelIds) {
|
||||||
: Array.from(currentEnabledIds);
|
currentEnabledIds.add(id);
|
||||||
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([]);
|
|
||||||
}
|
}
|
||||||
|
currentHasFilter = false;
|
||||||
|
await updateSessionModels(currentEnabledIds);
|
||||||
|
},
|
||||||
|
onClearAll: async () => {
|
||||||
|
currentEnabledIds.clear();
|
||||||
|
currentHasFilter = true;
|
||||||
|
await updateSessionModels(currentEnabledIds);
|
||||||
|
},
|
||||||
|
onToggleProvider: async (_provider, modelIds, enabled) => {
|
||||||
|
for (const id of modelIds) {
|
||||||
|
if (enabled) {
|
||||||
|
currentEnabledIds.add(id);
|
||||||
|
} else {
|
||||||
|
currentEnabledIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentHasFilter = true;
|
||||||
|
await updateSessionModels(currentEnabledIds);
|
||||||
|
},
|
||||||
|
onPersist: (enabledIds) => {
|
||||||
|
// Persist to settings
|
||||||
|
const newPatterns =
|
||||||
|
enabledIds.length === allModels.length
|
||||||
|
? undefined // All enabled = clear filter
|
||||||
|
: enabledIds;
|
||||||
|
this.settingsManager.setEnabledModels(newPatterns);
|
||||||
|
this.showStatus("Model selection saved to settings");
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
done();
|
done();
|
||||||
|
|
@ -2734,7 +2778,7 @@ export class InteractiveMode {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { component: selector, focus: selector.getSettingsList() };
|
return { component: selector, focus: selector };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue