feat(coding-agent-modes): implemented floating model selection system
- Introduced ModelPickerComponent to enable temporary, floating model selection overlays. - Centralized role management logic using a new resolveRoleAssignments function in the browser. - Streamlined model hub by removing legacy pick-mode logic and defaulting to fullscreen views. - Synchronized model picker state with the registry to ensure accurate offline refreshes.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added a compact session-only model picker (Alt+P) for quick model switching without changing roles
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated status event log to prioritize the most recent entries in the display window
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from "./login-dialog";
|
||||
export * from "./logout-account-selector";
|
||||
export * from "./model-browser";
|
||||
export * from "./model-hub";
|
||||
export * from "./model-picker";
|
||||
export * from "./oauth-selector";
|
||||
export * from "./queue-mode-selector";
|
||||
export * from "./read-tool-group";
|
||||
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import { getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles";
|
||||
import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
|
||||
import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles";
|
||||
import type { Settings } from "../../config/settings";
|
||||
import type { ModelPerfStats } from "../../session/agent-storage";
|
||||
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
|
||||
import { AUTO_THINKING, type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../../thinking";
|
||||
import { theme } from "../theme/theme";
|
||||
import {
|
||||
matchesSelectCancel,
|
||||
@@ -54,6 +55,67 @@ export interface RoleAssignment {
|
||||
/** Map of role id to its resolved assignment (absent roles are unresolved). */
|
||||
export type RoleAssignments = Record<string, RoleAssignment | undefined>;
|
||||
|
||||
/**
|
||||
* Resolve every known role to its display assignment: configured role values
|
||||
* resolve against `allModels`; unconfigured roles fall back to auto-selection
|
||||
* over `autoCandidates` (skipped when empty). Shared by the /models hub and
|
||||
* the alt+p session picker.
|
||||
*/
|
||||
export function resolveRoleAssignments(
|
||||
settings: Settings,
|
||||
allModels: ReadonlyArray<Model>,
|
||||
autoCandidates: ReadonlyArray<Model>,
|
||||
): RoleAssignments {
|
||||
const resolvedThinkingLevel = (
|
||||
role: string,
|
||||
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
|
||||
): ConfiguredThinkingLevel => {
|
||||
if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
|
||||
return resolved.thinkingLevel;
|
||||
}
|
||||
if (role === "default") {
|
||||
return parseConfiguredThinkingLevel(settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
|
||||
}
|
||||
return ThinkingLevel.Inherit;
|
||||
};
|
||||
|
||||
const roles: RoleAssignments = {};
|
||||
const matchPreferences = getModelMatchPreferences(settings);
|
||||
const knownRoles = getKnownRoleIds(settings);
|
||||
const configuredRoles = new Set<string>();
|
||||
const catalog = [...allModels];
|
||||
|
||||
for (const role of knownRoles) {
|
||||
const roleValue = settings.getModelRole(role);
|
||||
if (!roleValue) continue;
|
||||
configuredRoles.add(role);
|
||||
const resolved = resolveModelRoleValue(roleValue, catalog, { settings, matchPreferences });
|
||||
if (resolved.model) {
|
||||
roles[role] = {
|
||||
model: resolved.model,
|
||||
thinkingLevel: resolvedThinkingLevel(role, resolved),
|
||||
autoSelected: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (autoCandidates.length > 0) {
|
||||
const candidates = [...autoCandidates];
|
||||
for (const role of knownRoles) {
|
||||
if (configuredRoles.has(role)) continue;
|
||||
const resolved = resolveModelRoleValue(`pi/${role}`, candidates, { settings, matchPreferences });
|
||||
if (!resolved.model) continue;
|
||||
roles[role] = {
|
||||
model: resolved.model,
|
||||
thinkingLevel: resolvedThinkingLevel(role, resolved),
|
||||
autoSelected: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
/** Wrap raw models into browser items. */
|
||||
export function buildBrowserItems(models: ReadonlyArray<Model>): ModelBrowserItem[] {
|
||||
return models.map(model => ({
|
||||
@@ -256,7 +318,6 @@ export interface ModelBrowserOptions {
|
||||
disableOverContext?: boolean;
|
||||
/** Host-provided empty-state text (e.g. provider discovery status). */
|
||||
emptyText?: () => string | undefined;
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
/** Rendered rows before the list window: search row + blank. */
|
||||
@@ -295,6 +356,8 @@ export class ModelBrowser implements Component {
|
||||
#windowCount = 0;
|
||||
/** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
|
||||
#focused = true;
|
||||
/** `provider/id` of the session's active model; marked in rows and detail. */
|
||||
#currentSelector: string | undefined;
|
||||
|
||||
/** Enter or click-on-selected. */
|
||||
onActivate?: (item: ModelBrowserItem) => void;
|
||||
@@ -310,9 +373,11 @@ export class ModelBrowser implements Component {
|
||||
this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0;
|
||||
this.#disableOverContext = options.disableOverContext ?? false;
|
||||
this.#emptyText = options.emptyText;
|
||||
if (options.initialQuery) {
|
||||
this.#searchInput.setValue(options.initialQuery);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark `selector` as the session's active model (undefined clears the mark). */
|
||||
setCurrentSelector(selector: string | undefined): void {
|
||||
this.#currentSelector = selector;
|
||||
}
|
||||
|
||||
/** Replace the scope's base items; the live query re-applies and selection is pinned by selector. */
|
||||
@@ -641,10 +706,12 @@ export class ModelBrowser implements Component {
|
||||
const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
|
||||
const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
|
||||
const name = selected ? theme.fg("accent", item.id) : item.id;
|
||||
const currentMark =
|
||||
item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : "";
|
||||
const overLimit = disabled
|
||||
? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
|
||||
: "";
|
||||
let left = `${prefix}${providerPrefix}${name}${overLimit}`;
|
||||
let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`;
|
||||
|
||||
// Perf column collapses entirely when no visible row has measurements.
|
||||
const perfCol =
|
||||
@@ -691,6 +758,9 @@ export class ModelBrowser implements Component {
|
||||
}
|
||||
|
||||
const chips: string[] = [];
|
||||
if (selected.selector === this.#currentSelector) {
|
||||
chips.push(theme.fg("success", `${theme.status.enabled} current`));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const pushRole = (role: string) => {
|
||||
if (seen.has(role)) return;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* {@link ModelBrowser} body. The Roles view manages assignments directly:
|
||||
* pick a role, pick a model, adjust thinking in an inline strip, or clear the
|
||||
* role back to auto-selection. Locked providers forward to the /login flow.
|
||||
* Fully mouse-navigable (hover, wheel, click).
|
||||
* Fully mouse-navigable (hover, wheel, click). Session-only switching lives
|
||||
* in the compact alt+p picker ({@link ./model-picker}).
|
||||
*/
|
||||
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
@@ -27,15 +28,9 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
|
||||
import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles";
|
||||
import type { Settings } from "../../config/settings";
|
||||
import {
|
||||
AUTO_THINKING,
|
||||
type ConfiguredThinkingLevel,
|
||||
getConfiguredThinkingLevelMetadata,
|
||||
parseConfiguredThinkingLevel,
|
||||
} from "../../thinking";
|
||||
import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||||
import {
|
||||
@@ -43,15 +38,13 @@ import {
|
||||
ModelBrowser,
|
||||
type ModelBrowserItem,
|
||||
type RoleAssignments,
|
||||
resolveRoleAssignments,
|
||||
sortModelItems,
|
||||
thinkingLevelGlyph,
|
||||
} from "./model-browser";
|
||||
import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box";
|
||||
import { renderSegmentTrack } from "./segment-track";
|
||||
|
||||
/** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
|
||||
export type ModelHubMode = "roles" | "pick";
|
||||
|
||||
/**
|
||||
* A row of the Roles view: a role, a model/wildcard chain-key header, one of a
|
||||
* chain's fallback entries, or the trailing "+ New role…". Fallback rows under
|
||||
@@ -89,8 +82,6 @@ export interface ModelHubCallbacks {
|
||||
onUnassign: (role: string) => void;
|
||||
/** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
|
||||
onFallbackChainChange?: (role: string, chain: string[]) => void;
|
||||
/** Pick-mode activation: session-only switch or embedded pick. */
|
||||
onPick?: (model: Model, selector: string) => void;
|
||||
/** Locked provider activation: forward to the /login flow. */
|
||||
onLoginRequest?: (providerId: string) => void;
|
||||
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
|
||||
@@ -99,14 +90,8 @@ export interface ModelHubCallbacks {
|
||||
}
|
||||
|
||||
export interface ModelHubOptions {
|
||||
mode?: ModelHubMode;
|
||||
/** Session token count; in pick mode, models with smaller context windows are disabled. */
|
||||
currentContextTokens?: number;
|
||||
/** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
|
||||
initialProviderId?: string;
|
||||
/** Status-row hint shown in pick mode. */
|
||||
pickerHint?: string;
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
interface SidebarEntry {
|
||||
@@ -158,8 +143,6 @@ const RECENT_LIMIT = 15;
|
||||
const SIDEBAR_MIN_WIDTH = 18;
|
||||
const SIDEBAR_MAX_WIDTH = 26;
|
||||
|
||||
const PICK_MODE_HINT = "Session-only switch — role models stay unchanged";
|
||||
|
||||
/**
|
||||
* Providers already auto-refreshed this process. Selecting a provider fetches
|
||||
* its live model list at most once per application lifetime (surviving hub
|
||||
@@ -182,8 +165,6 @@ export class ModelHubComponent implements Component {
|
||||
#registry: ModelRegistry;
|
||||
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
#callbacks: ModelHubCallbacks;
|
||||
#mode: ModelHubMode;
|
||||
#pickerHint: string;
|
||||
|
||||
#browser: ModelBrowser;
|
||||
#roles: RoleAssignments = {};
|
||||
@@ -250,14 +231,9 @@ export class ModelHubComponent implements Component {
|
||||
this.#registry = registry;
|
||||
this.#scopedModels = scopedModels;
|
||||
this.#callbacks = callbacks;
|
||||
this.#mode = options.mode ?? "roles";
|
||||
this.#pickerHint = options.pickerHint ?? PICK_MODE_HINT;
|
||||
|
||||
this.#browser = new ModelBrowser(settings, {
|
||||
currentContextTokens: options.currentContextTokens,
|
||||
disableOverContext: this.#mode === "pick",
|
||||
emptyText: () => this.#emptyStateMessage(),
|
||||
initialQuery: options.initialQuery,
|
||||
});
|
||||
this.#browser.onActivate = item => this.#activateItem(item);
|
||||
this.#browser.onCancel = () => this.#callbacks.onCancel();
|
||||
@@ -275,10 +251,6 @@ export class ModelHubComponent implements Component {
|
||||
this.#setActiveEntry("all");
|
||||
}
|
||||
|
||||
if (this.#mode === "pick") {
|
||||
this.#focus = "list";
|
||||
}
|
||||
|
||||
// Reconcile with cached discovery state in the background. A --models
|
||||
// scope is registry-independent, so the offline reload would only repeat
|
||||
// the synchronous hydration above.
|
||||
@@ -314,62 +286,10 @@ export class ModelHubComponent implements Component {
|
||||
return getKnownRoleIds(this.#settings).filter(role => !getRoleInfo(role, this.#settings).hidden);
|
||||
}
|
||||
|
||||
#getResolvedRoleThinkingLevel(
|
||||
role: string,
|
||||
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
|
||||
): ConfiguredThinkingLevel {
|
||||
if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
|
||||
return resolved.thinkingLevel;
|
||||
}
|
||||
if (role === "default") {
|
||||
return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
|
||||
}
|
||||
return ThinkingLevel.Inherit;
|
||||
}
|
||||
|
||||
/** Resolve every known role: configured values first, auto-selection for the rest. */
|
||||
#reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
|
||||
const nextRoles: RoleAssignments = {};
|
||||
const allModels = this.#scopedModels.length > 0 ? [...autoCandidates] : this.#registry.getAll();
|
||||
const matchPreferences = getModelMatchPreferences(this.#settings);
|
||||
const knownRoles = getKnownRoleIds(this.#settings);
|
||||
const configuredRoles = new Set<string>();
|
||||
|
||||
for (const role of knownRoles) {
|
||||
const roleValue = this.#settings.getModelRole(role);
|
||||
if (!roleValue) continue;
|
||||
configuredRoles.add(role);
|
||||
const resolved = resolveModelRoleValue(roleValue, allModels, {
|
||||
settings: this.#settings,
|
||||
matchPreferences,
|
||||
});
|
||||
if (resolved.model) {
|
||||
nextRoles[role] = {
|
||||
model: resolved.model,
|
||||
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
|
||||
autoSelected: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (autoCandidates.length > 0) {
|
||||
const candidates = [...autoCandidates];
|
||||
for (const role of knownRoles) {
|
||||
if (configuredRoles.has(role)) continue;
|
||||
const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
|
||||
settings: this.#settings,
|
||||
matchPreferences,
|
||||
});
|
||||
if (!resolved.model) continue;
|
||||
nextRoles[role] = {
|
||||
model: resolved.model,
|
||||
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
|
||||
autoSelected: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.#roles = nextRoles;
|
||||
const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll();
|
||||
this.#roles = resolveRoleAssignments(this.#settings, allModels, autoCandidates);
|
||||
}
|
||||
|
||||
/** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
|
||||
@@ -475,16 +395,15 @@ export class ModelHubComponent implements Component {
|
||||
|
||||
// Roles leads the fixed section so downward hops from Recent head into
|
||||
// model scopes instead of being captured by the roles view.
|
||||
const fixed: SidebarEntry[] = [];
|
||||
if (this.#mode === "roles") {
|
||||
fixed.push({
|
||||
const fixed: SidebarEntry[] = [
|
||||
{
|
||||
id: "roles",
|
||||
kind: "roles",
|
||||
label: "Roles",
|
||||
annotation: `${assignedCount}/${visibleRoles.length}`,
|
||||
});
|
||||
}
|
||||
fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
|
||||
},
|
||||
{ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) },
|
||||
];
|
||||
|
||||
this.#fixedEntries = fixed;
|
||||
this.#unlockedProviderEntries = [...unlocked]
|
||||
@@ -811,10 +730,6 @@ export class ModelHubComponent implements Component {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#activateItem(item: ModelBrowserItem): void {
|
||||
if (this.#mode === "pick") {
|
||||
this.#callbacks.onPick?.(item.model, item.selector);
|
||||
return;
|
||||
}
|
||||
if (this.#assigning) {
|
||||
const target = this.#assigning;
|
||||
this.#assigning = null;
|
||||
@@ -917,7 +832,7 @@ export class ModelHubComponent implements Component {
|
||||
const strip = this.#strip;
|
||||
this.#strip = null;
|
||||
this.#chipRanges = [];
|
||||
if (strip?.kind === "thinking" && strip.returnToRoles && this.#mode === "roles") {
|
||||
if (strip?.kind === "thinking" && strip.returnToRoles) {
|
||||
this.#setActiveEntry("roles");
|
||||
this.#focus = "list";
|
||||
}
|
||||
@@ -1030,14 +945,12 @@ export class ModelHubComponent implements Component {
|
||||
}
|
||||
this.#setFallbackChain(target.role, chain);
|
||||
this.#browser.setQuery("");
|
||||
if (this.#mode === "roles") {
|
||||
this.#setActiveEntry("roles");
|
||||
this.#focus = "list";
|
||||
const rowIndex = this.#rolesRows.findIndex(
|
||||
row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
|
||||
);
|
||||
if (rowIndex >= 0) this.#roleIndex = rowIndex;
|
||||
}
|
||||
this.#setActiveEntry("roles");
|
||||
this.#focus = "list";
|
||||
const rowIndex = this.#rolesRows.findIndex(
|
||||
row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
|
||||
);
|
||||
if (rowIndex >= 0) this.#roleIndex = rowIndex;
|
||||
}
|
||||
|
||||
/** Persist `role`'s chain through the host callback and rebuild dependent state. */
|
||||
@@ -1076,10 +989,8 @@ export class ModelHubComponent implements Component {
|
||||
#cancelAssign(): void {
|
||||
this.#assigning = null;
|
||||
this.#browser.setQuery("");
|
||||
if (this.#mode === "roles") {
|
||||
this.#setActiveEntry("roles");
|
||||
this.#focus = "list";
|
||||
}
|
||||
this.#setActiveEntry("roles");
|
||||
this.#focus = "list";
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -1655,7 +1566,7 @@ export class ModelHubComponent implements Component {
|
||||
let text: string;
|
||||
switch (entry.kind) {
|
||||
case "recent":
|
||||
text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
|
||||
text = `Recently used models${scopedSuffix}`;
|
||||
break;
|
||||
case "roles":
|
||||
text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
|
||||
@@ -1670,7 +1581,7 @@ export class ModelHubComponent implements Component {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
text = this.#mode === "pick" ? this.#pickerHint : `All available models${scopedSuffix}`;
|
||||
text = `All available models${scopedSuffix}`;
|
||||
break;
|
||||
}
|
||||
if (this.#configError && entry.kind !== "provider") {
|
||||
@@ -1895,9 +1806,6 @@ export class ModelHubComponent implements Component {
|
||||
}
|
||||
const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
|
||||
const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
|
||||
if (this.#mode === "pick") {
|
||||
return `Enter use for this session · ${arrows} · type to search${refresh} · Esc close`;
|
||||
}
|
||||
return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
|
||||
}
|
||||
|
||||
@@ -1992,9 +1900,8 @@ export class ModelHubComponent implements Component {
|
||||
|
||||
const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows);
|
||||
|
||||
const title = this.#mode === "pick" ? "Switch Model" : "Models";
|
||||
const out: string[] = [];
|
||||
out.push(topBorderSplit(width, title, sidebarWidth));
|
||||
out.push(topBorderSplit(width, "Models", sidebarWidth));
|
||||
this.#contentRowStart = out.length;
|
||||
for (let i = 0; i < contentRows; i++) {
|
||||
out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth));
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Compact session-model picker (alt+p / `/switch`): a bottom-anchored
|
||||
* floating overlay hosting just a {@link ModelBrowser} — no provider sidebar,
|
||||
* no role management. Picking a model switches the current session only;
|
||||
* role assignment lives in the fullscreen /models hub ({@link ./model-hub}).
|
||||
*/
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Component, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import type { Settings } from "../../config/settings";
|
||||
import { theme } from "../theme/theme";
|
||||
import { buildBrowserItems, ModelBrowser, resolveRoleAssignments, sortModelItems } from "./model-browser";
|
||||
import type { ScopedModelItem } from "./model-hub";
|
||||
import { bottomBorder, row, topBorder } from "./overlay-box";
|
||||
|
||||
export interface ModelPickerCallbacks {
|
||||
/** A model was chosen for a session-only switch. `selector` is `provider/id`. */
|
||||
onPick: (model: Model, selector: string) => void;
|
||||
/** The picker was dismissed. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface ModelPickerOptions {
|
||||
/** Session token count; models with smaller context windows are disabled. */
|
||||
currentContextTokens?: number;
|
||||
/** `provider/id` of the session's active model; highlighted and preselected. */
|
||||
currentSelector?: string;
|
||||
}
|
||||
|
||||
/** Fixed chrome rows: top border, status row, footer, bottom border. */
|
||||
const CHROME_ROWS = 4;
|
||||
/** Rows the browser renders around its list window (search + blank, blank + two detail rows). */
|
||||
const BROWSER_FRAME_ROWS = 5;
|
||||
/** Minimum rows for the browser list window on short terminals. */
|
||||
const MIN_VISIBLE = 5;
|
||||
/** Fraction of the terminal height the floating overlay occupies. */
|
||||
const HEIGHT_FRACTION = 0.4;
|
||||
|
||||
const STATUS_HINT = "Session-only switch — role models stay unchanged";
|
||||
const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · Esc close";
|
||||
|
||||
/**
|
||||
* The alt+p picker component. Hosted as a non-fullscreen bottom-anchored
|
||||
* overlay (`ui.showOverlay(..., { anchor: "bottom-center" })`); keyboard-only,
|
||||
* since mouse tracking is reserved for fullscreen overlays.
|
||||
*/
|
||||
export class ModelPickerComponent implements Component {
|
||||
#tui: TUI;
|
||||
#settings: Settings;
|
||||
#registry: ModelRegistry;
|
||||
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
#browser: ModelBrowser;
|
||||
#configError: string | undefined;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
settings: Settings,
|
||||
registry: ModelRegistry,
|
||||
scopedModels: ReadonlyArray<ScopedModelItem>,
|
||||
callbacks: ModelPickerCallbacks,
|
||||
options: ModelPickerOptions = {},
|
||||
) {
|
||||
this.#tui = tui;
|
||||
this.#settings = settings;
|
||||
this.#registry = registry;
|
||||
this.#scopedModels = scopedModels;
|
||||
|
||||
this.#browser = new ModelBrowser(settings, {
|
||||
currentContextTokens: options.currentContextTokens,
|
||||
disableOverContext: true,
|
||||
});
|
||||
this.#browser.onActivate = item => callbacks.onPick(item.model, item.selector);
|
||||
this.#browser.onCancel = () => callbacks.onCancel();
|
||||
this.#browser.setCurrentSelector(options.currentSelector);
|
||||
|
||||
// Hydrate synchronously from the current registry snapshot so the first
|
||||
// Enter after opening acts on cached models instead of being dropped
|
||||
// while the offline refresh promise is still pending.
|
||||
this.#syncFromRegistryState();
|
||||
if (options.currentSelector) {
|
||||
this.#browser.selectSelector(options.currentSelector);
|
||||
}
|
||||
|
||||
// Reconcile with cached discovery state in the background. A --models
|
||||
// scope is registry-independent, so the offline reload would only repeat
|
||||
// the synchronous hydration above.
|
||||
if (this.#scopedModels.length === 0) {
|
||||
this.#registry
|
||||
.refresh("offline")
|
||||
.then(() => this.#syncFromRegistryState())
|
||||
.catch(error => {
|
||||
this.#configError = error instanceof Error ? error.message : String(error);
|
||||
})
|
||||
.finally(() => this.#tui.requestRender());
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
/** Rebuild items and role chips from the registry's in-memory state (selection is pinned by selector). */
|
||||
#syncFromRegistryState(): void {
|
||||
let models: ReadonlyArray<Model>;
|
||||
if (this.#scopedModels.length > 0) {
|
||||
models = this.#scopedModels.map(scoped => scoped.model);
|
||||
this.#configError = undefined;
|
||||
} else {
|
||||
const loadError = this.#registry.getError();
|
||||
this.#configError = loadError ? String(loadError) : undefined;
|
||||
try {
|
||||
models = this.#registry.getAvailable();
|
||||
} catch (error) {
|
||||
this.#configError = error instanceof Error ? error.message : String(error);
|
||||
models = [];
|
||||
}
|
||||
}
|
||||
|
||||
const allModels = this.#scopedModels.length > 0 ? models : this.#registry.getAll();
|
||||
const roles = resolveRoleAssignments(this.#settings, allModels, models);
|
||||
const storage = this.#settings.getStorage();
|
||||
const mruOrder = storage?.getModelUsageOrder() ?? [];
|
||||
const items = buildBrowserItems(models);
|
||||
sortModelItems(items, { roles, mruOrder });
|
||||
this.#browser.setRoles(roles);
|
||||
this.#browser.setMruOrder(mruOrder);
|
||||
this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
|
||||
this.#browser.setItems(items);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Mouse tracking is off outside fullscreen overlays; drop any stray SGR
|
||||
// reports instead of feeding them to the search input.
|
||||
if (data.startsWith("\x1b[<")) return;
|
||||
this.#browser.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const termRows = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
|
||||
const listBudget = Math.floor(termRows * HEIGHT_FRACTION) - CHROME_ROWS - BROWSER_FRAME_ROWS;
|
||||
this.#browser.setMaxVisible(Math.max(MIN_VISIBLE, listBudget));
|
||||
|
||||
const inner = Math.max(1, width - 4);
|
||||
const status = this.#configError
|
||||
? theme.fg("error", ` ${this.#configError}`)
|
||||
: theme.fg("muted", ` ${STATUS_HINT}`);
|
||||
|
||||
const out: string[] = [];
|
||||
out.push(topBorder(width, "Switch Model"));
|
||||
out.push(row(status, width));
|
||||
for (const line of this.#browser.render(inner)) {
|
||||
out.push(row(line, width));
|
||||
}
|
||||
out.push(row(theme.fg("dim", FOOTER_HINT), width));
|
||||
out.push(bottomBorder(width));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,8 @@ import { ExtensionDashboard } from "../components/extensions";
|
||||
import { HistorySearchComponent } from "../components/history-search";
|
||||
import { LoginDialogComponent } from "../components/login-dialog";
|
||||
import { LogoutAccountSelectorComponent } from "../components/logout-account-selector";
|
||||
import { ModelHubComponent, type ModelHubMode } from "../components/model-hub";
|
||||
import { ModelHubComponent } from "../components/model-hub";
|
||||
import { ModelPickerComponent } from "../components/model-picker";
|
||||
import { OAuthSelectorComponent } from "../components/oauth-selector";
|
||||
import { PluginSelectorComponent } from "../components/plugin-selector";
|
||||
import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
|
||||
@@ -586,7 +587,65 @@ export class SelectorController {
|
||||
}
|
||||
|
||||
showModelSelector(options?: { temporaryOnly?: boolean }): void {
|
||||
this.#showModelHub({ mode: options?.temporaryOnly ? "pick" : "roles" });
|
||||
if (options?.temporaryOnly) {
|
||||
this.#showModelPicker();
|
||||
return;
|
||||
}
|
||||
this.#showModelHub({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact session-only model picker (alt+p / `/switch`): a floating
|
||||
* bottom-anchored overlay over the transcript — just the model list, no
|
||||
* provider sidebar, no role management. The current model is highlighted
|
||||
* and preselected.
|
||||
*/
|
||||
#showModelPicker(): void {
|
||||
const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
|
||||
const current = this.ctx.session.model;
|
||||
let overlayHandle: OverlayHandle | undefined;
|
||||
let closed = false;
|
||||
const done = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
overlayHandle?.hide();
|
||||
this.focusActiveEditorArea();
|
||||
this.ctx.ui.requestRender();
|
||||
};
|
||||
const picker = new ModelPickerComponent(
|
||||
this.ctx.ui,
|
||||
this.ctx.settings,
|
||||
this.ctx.session.modelRegistry,
|
||||
this.ctx.session.scopedModels,
|
||||
{
|
||||
onPick: async (model, selector) => {
|
||||
try {
|
||||
// Session-only: update agent state but don't persist the model to settings.
|
||||
await this.ctx.session.setModelTemporary(model);
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
|
||||
this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`);
|
||||
done();
|
||||
} catch (error) {
|
||||
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
onCancel: done,
|
||||
},
|
||||
{
|
||||
currentContextTokens,
|
||||
currentSelector: current ? `${current.provider}/${current.id}` : undefined,
|
||||
},
|
||||
);
|
||||
overlayHandle = this.ctx.ui.showOverlay(picker, {
|
||||
anchor: "bottom-center",
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
margin: 0,
|
||||
});
|
||||
this.ctx.ui.setFocus(picker);
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,13 +654,13 @@ export class SelectorController {
|
||||
* untouched underneath. `initialProviderId` preselects a provider's sidebar
|
||||
* entry — used when reopening the hub after a /login round-trip.
|
||||
*/
|
||||
#showModelHub(hubOptions: { mode: ModelHubMode; initialProviderId?: string }): void {
|
||||
#showModelHub(hubOptions: { initialProviderId?: string }): void {
|
||||
const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
|
||||
let overlayHandle: OverlayHandle | undefined;
|
||||
let hub: ModelHubComponent | undefined;
|
||||
let closed = false;
|
||||
const done = () => {
|
||||
// Re-entrant guard: cancel paths (Esc, pick, login forward) may race;
|
||||
// Re-entrant guard: cancel paths (Esc, login forward) may race;
|
||||
// the overlay must hide exactly once.
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
@@ -686,21 +745,6 @@ export class SelectorController {
|
||||
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
onPick: async (model, selector) => {
|
||||
try {
|
||||
// Session-only: update agent state but don't persist the model to settings.
|
||||
await this.ctx.session.setModelTemporary(model);
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
|
||||
this.ctx.showStatus(
|
||||
`Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
|
||||
);
|
||||
done();
|
||||
} catch (error) {
|
||||
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
onLoginRequest: providerId => {
|
||||
done();
|
||||
void this.#loginThenReopenModelHub(providerId);
|
||||
@@ -718,8 +762,6 @@ export class SelectorController {
|
||||
onCancel: () => done(),
|
||||
},
|
||||
{
|
||||
mode: hubOptions.mode,
|
||||
currentContextTokens,
|
||||
initialProviderId: hubOptions.initialProviderId,
|
||||
},
|
||||
);
|
||||
@@ -738,7 +780,7 @@ export class SelectorController {
|
||||
async #loginThenReopenModelHub(providerId: string): Promise<void> {
|
||||
const succeeded = await this.#handleOAuthLogin(providerId);
|
||||
if (succeeded) {
|
||||
this.#showModelHub({ mode: "roles", initialProviderId: providerId });
|
||||
this.#showModelHub({ initialProviderId: providerId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ interface HubHarness {
|
||||
hub: ModelHubComponent;
|
||||
onAssign: ReturnType<typeof vi.fn>;
|
||||
onUnassign: ReturnType<typeof vi.fn>;
|
||||
onPick: ReturnType<typeof vi.fn>;
|
||||
onLoginRequest: ReturnType<typeof vi.fn>;
|
||||
onCancel: ReturnType<typeof vi.fn>;
|
||||
onFallbackChainChange: Mock<(role: string, chain: string[]) => void>;
|
||||
@@ -98,7 +97,6 @@ function createHub(options: {
|
||||
const ui = { requestRender: vi.fn(), terminal: { rows: 40 } } as unknown as TUI;
|
||||
const onAssign = vi.fn();
|
||||
const onUnassign = vi.fn();
|
||||
const onPick = vi.fn();
|
||||
const onLoginRequest = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
// Mirror the controller: persist chain edits so the hub's re-read sees them.
|
||||
@@ -119,7 +117,6 @@ function createHub(options: {
|
||||
{
|
||||
onAssign: options.callbacks?.onAssign ?? onAssign,
|
||||
onUnassign: options.callbacks?.onUnassign ?? onUnassign,
|
||||
onPick: options.callbacks?.onPick ?? onPick,
|
||||
onLoginRequest: options.callbacks?.onLoginRequest ?? onLoginRequest,
|
||||
onCycleOrderChange: options.callbacks?.onCycleOrderChange,
|
||||
onFallbackChainChange: options.callbacks?.onFallbackChainChange ?? onFallbackChainChange,
|
||||
@@ -128,12 +125,13 @@ function createHub(options: {
|
||||
options.hub,
|
||||
);
|
||||
openHubs.push(hub);
|
||||
return { hub, onAssign, onUnassign, onPick, onLoginRequest, onCancel, onFallbackChainChange };
|
||||
return { hub, onAssign, onUnassign, onLoginRequest, onCancel, onFallbackChainChange };
|
||||
}
|
||||
|
||||
const DOWN = "\x1b[B";
|
||||
const UP = "\x1b[A";
|
||||
const LEFT = "\x1b[D";
|
||||
const ESC = "\x1b";
|
||||
|
||||
describe("ModelHub", () => {
|
||||
beforeAll(async () => {
|
||||
@@ -608,86 +606,10 @@ describe("ModelHub", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pick mode", () => {
|
||||
test("disables models below the current context size and picks the first enabled one", () => {
|
||||
const small = makeModel("test", "a-small", 4096);
|
||||
const large = makeModel("test", "b-large", 128_000);
|
||||
const { hub, onPick } = createHub({
|
||||
models: [small, large],
|
||||
scoped: true,
|
||||
hub: { mode: "pick", currentContextTokens: 6000 },
|
||||
});
|
||||
installTestTheme();
|
||||
|
||||
const rendered = normalize(hub.render(220));
|
||||
expect(rendered).toContain("a-small");
|
||||
expect(rendered).toContain("context>4.1k");
|
||||
expect(rendered).toContain("Session-only switch");
|
||||
|
||||
hub.handleInput("\n");
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick.mock.calls[0]?.[0]).toBe(large);
|
||||
});
|
||||
|
||||
test("uses cached models for Enter while the offline refresh is still pending", () => {
|
||||
const cached = makeModel("test", "cached-fast");
|
||||
const refreshGate = Promise.withResolvers<void>();
|
||||
const refresh = vi.fn(() => refreshGate.promise);
|
||||
const { hub, onPick } = createHub({
|
||||
models: [cached],
|
||||
registry: { refresh },
|
||||
hub: { mode: "pick" },
|
||||
});
|
||||
installTestTheme();
|
||||
|
||||
hub.handleInput("\n");
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick.mock.calls[0]?.[0]).toBe(cached);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
refreshGate.resolve();
|
||||
});
|
||||
|
||||
test("focuses list mode initially in pick mode", () => {
|
||||
const model = makeModel("test", "test-model");
|
||||
const { hub } = createHub({
|
||||
models: [model],
|
||||
hub: { mode: "pick" },
|
||||
});
|
||||
const rendered = normalize(hub.render(220));
|
||||
expect(rendered).toContain("↑/↓ models · ← providers");
|
||||
});
|
||||
|
||||
test("focuses scope mode initially in roles mode", () => {
|
||||
const model = makeModel("test", "test-model");
|
||||
const { hub } = createHub({
|
||||
models: [model],
|
||||
hub: { mode: "roles" },
|
||||
});
|
||||
const rendered = normalize(hub.render(220));
|
||||
expect(rendered).toContain("↑/↓ providers · → models");
|
||||
});
|
||||
|
||||
test("keeps the highlighted model when a background refresh reorders the list", async () => {
|
||||
const modelBb = makeModel("test", "bb-model");
|
||||
const modelCc = makeModel("test", "cc-model");
|
||||
const modelAa = makeModel("test", "aa-model");
|
||||
let available = [modelBb, modelCc];
|
||||
const refreshGate = Promise.withResolvers<void>();
|
||||
const { hub, onPick } = createHub({
|
||||
models: () => available,
|
||||
registry: { refresh: () => refreshGate.promise },
|
||||
hub: { mode: "pick" },
|
||||
});
|
||||
installTestTheme();
|
||||
|
||||
hub.handleInput(DOWN); // highlight cc-model
|
||||
available = [modelAa, modelBb, modelCc];
|
||||
refreshGate.resolve();
|
||||
await Bun.sleep(0);
|
||||
|
||||
hub.handleInput("\n");
|
||||
expect(onPick.mock.calls[0]?.[0]?.id).toBe("cc-model");
|
||||
});
|
||||
test("focuses the scope pane initially", () => {
|
||||
const { hub } = createHub({ models: [makeModel("test", "test-model")] });
|
||||
const rendered = normalize(hub.render(220));
|
||||
expect(rendered).toContain("↑/↓ providers · → models");
|
||||
});
|
||||
|
||||
describe("mouse wheel", () => {
|
||||
@@ -700,12 +622,15 @@ describe("ModelHub", () => {
|
||||
|
||||
test("wheel pans the model list without moving the selection and clamps at the ends", () => {
|
||||
const models = Array.from({ length: 40 }, (_, i) => makeModel("test", `model-${String(i).padStart(2, "0")}`));
|
||||
const { hub, onPick } = createHub({ models, scoped: true, hub: { mode: "pick" } });
|
||||
const { hub } = createHub({ models, scoped: true });
|
||||
|
||||
const before = normalize(hub.render(220)); // establishes mouse geometry
|
||||
// Enter opens the role strip for the selected model — its footer
|
||||
// (`<model-id> → …`) identifies the selection.
|
||||
hub.handleInput("\n");
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
const initiallySelected = onPick.mock.calls[0]?.[0];
|
||||
const initialStrip = footerLine(hub.render(220));
|
||||
expect(initialStrip).toContain("→");
|
||||
hub.handleInput(ESC); // close the strip
|
||||
|
||||
// Panning reveals rows that were below the fold...
|
||||
for (let i = 0; i < 8; i++) hub.handleInput(WHEEL_DOWN_BODY);
|
||||
@@ -715,10 +640,10 @@ describe("ModelHub", () => {
|
||||
const revealed = [...modelIdsIn(panned)].filter(id => !beforeIds.has(id));
|
||||
expect(revealed.length).toBeGreaterThan(0);
|
||||
|
||||
// ...but never moves the selection: Enter still picks the same model.
|
||||
// ...but never moves the selection: Enter still opens the same model's strip.
|
||||
hub.handleInput("\n");
|
||||
expect(onPick).toHaveBeenCalledTimes(2);
|
||||
expect(onPick.mock.calls[1]?.[0]).toBe(initiallySelected);
|
||||
expect(footerLine(hub.render(220))).toBe(initialStrip);
|
||||
hub.handleInput(ESC);
|
||||
|
||||
// The window clamps at the bottom instead of wrapping back to the top...
|
||||
for (let i = 0; i < 500; i++) hub.handleInput(WHEEL_DOWN_BODY);
|
||||
@@ -773,14 +698,9 @@ describe("ModelHub", () => {
|
||||
test("search inside a provider scope keeps that provider's model (#4522)", () => {
|
||||
const openrouterGlm = makeModel("openrouter", "z-ai/glm-5.2");
|
||||
const customGlm = makeModel("custom-provider", "glm-5.2");
|
||||
const { hub, onPick } = createHub({
|
||||
models: [openrouterGlm, customGlm],
|
||||
hub: { mode: "pick" },
|
||||
});
|
||||
const { hub } = createHub({ models: [openrouterGlm, customGlm] });
|
||||
installTestTheme();
|
||||
|
||||
// Focus scope first to allow scope-hopping
|
||||
hub.handleInput("\t");
|
||||
// Scope-hop: All models → custom-provider → openrouter.
|
||||
hub.handleInput(DOWN);
|
||||
hub.handleInput(DOWN);
|
||||
@@ -789,9 +709,9 @@ describe("ModelHub", () => {
|
||||
for (const ch of "glm-5.2") hub.handleInput(ch);
|
||||
hub.handleInput("\n");
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick.mock.calls[0]?.[0]?.provider).toBe("openrouter");
|
||||
expect(onPick.mock.calls[0]?.[0]?.id).toBe("z-ai/glm-5.2");
|
||||
// The role strip opened for the provider-scoped match, not the
|
||||
// identically named custom-provider model.
|
||||
expect(footerLine(hub.render(220))).toContain("z-ai/glm-5.2 →");
|
||||
});
|
||||
|
||||
test("search on All models spans every provider", () => {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeAll, describe, expect, type Mock, test, vi } from "bun:test";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { ModelPickerComponent, type ModelPickerOptions } from "@oh-my-pi/pi-coding-agent/modes/components/model-picker";
|
||||
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { TUI } from "@oh-my-pi/pi-tui";
|
||||
|
||||
function normalize(lines: readonly string[]): string {
|
||||
return stripVTControlCharacters(lines.join("\n")).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function makeModel(provider: string, id: string, contextWindow = 128_000): Model {
|
||||
return buildModel({
|
||||
id,
|
||||
name: id,
|
||||
api: "ollama-chat",
|
||||
provider,
|
||||
baseUrl: "https://example.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow,
|
||||
maxTokens: 1024,
|
||||
});
|
||||
}
|
||||
|
||||
let testTheme = await getThemeByName("dark");
|
||||
|
||||
function installTestTheme(): void {
|
||||
if (!testTheme) {
|
||||
throw new Error("Failed to load dark theme for ModelPicker tests");
|
||||
}
|
||||
setThemeInstance(testTheme);
|
||||
}
|
||||
|
||||
interface RegistryOverrides {
|
||||
refresh?: (mode: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface PickerHarness {
|
||||
picker: ModelPickerComponent;
|
||||
onPick: Mock<(model: Model, selector: string) => void>;
|
||||
onCancel: Mock<() => void>;
|
||||
}
|
||||
|
||||
function createPicker(options: {
|
||||
models: Model[] | (() => Model[]);
|
||||
scoped?: boolean;
|
||||
settings?: Settings;
|
||||
registry?: RegistryOverrides;
|
||||
picker?: ModelPickerOptions;
|
||||
}): PickerHarness {
|
||||
installTestTheme();
|
||||
const modelsFn = typeof options.models === "function" ? options.models : () => options.models as Model[];
|
||||
const settings = options.settings ?? Settings.isolated({});
|
||||
const registry = {
|
||||
refresh: options.registry?.refresh ?? (async () => {}),
|
||||
getError: () => undefined,
|
||||
getAvailable: modelsFn,
|
||||
getAll: modelsFn,
|
||||
} as unknown as ModelRegistry;
|
||||
const ui = { requestRender: vi.fn(), terminal: { rows: 40 } } as unknown as TUI;
|
||||
const onPick = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const picker = new ModelPickerComponent(
|
||||
ui,
|
||||
settings,
|
||||
registry,
|
||||
options.scoped ? modelsFn().map(model => ({ model })) : [],
|
||||
{ onPick, onCancel },
|
||||
options.picker ?? {},
|
||||
);
|
||||
return { picker, onPick, onCancel };
|
||||
}
|
||||
|
||||
const DOWN = "\x1b[B";
|
||||
const ESC = "\x1b";
|
||||
|
||||
describe("ModelPicker", () => {
|
||||
beforeAll(async () => {
|
||||
testTheme = await getThemeByName("dark");
|
||||
if (!testTheme) {
|
||||
throw new Error("Failed to load dark theme for ModelPicker tests");
|
||||
}
|
||||
});
|
||||
|
||||
test("disables models below the current context size and picks the first enabled one", () => {
|
||||
const small = makeModel("test", "a-small", 4096);
|
||||
const large = makeModel("test", "b-large", 128_000);
|
||||
const { picker, onPick } = createPicker({
|
||||
models: [small, large],
|
||||
scoped: true,
|
||||
picker: { currentContextTokens: 6000 },
|
||||
});
|
||||
|
||||
const rendered = normalize(picker.render(220));
|
||||
expect(rendered).toContain("a-small");
|
||||
expect(rendered).toContain("context>4.1k");
|
||||
expect(rendered).toContain("Session-only switch");
|
||||
|
||||
picker.handleInput("\n");
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick.mock.calls[0]?.[0]).toBe(large);
|
||||
});
|
||||
|
||||
test("uses cached models for Enter while the offline refresh is still pending", () => {
|
||||
const cached = makeModel("test", "cached-fast");
|
||||
const refreshGate = Promise.withResolvers<void>();
|
||||
const refresh = vi.fn(() => refreshGate.promise);
|
||||
const { picker, onPick } = createPicker({
|
||||
models: [cached],
|
||||
registry: { refresh },
|
||||
});
|
||||
|
||||
picker.handleInput("\n");
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick.mock.calls[0]?.[0]).toBe(cached);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
refreshGate.resolve();
|
||||
});
|
||||
|
||||
test("keeps the highlighted model when a background refresh reorders the list", async () => {
|
||||
const modelBb = makeModel("test", "bb-model");
|
||||
const modelCc = makeModel("test", "cc-model");
|
||||
const modelAa = makeModel("test", "aa-model");
|
||||
let available = [modelBb, modelCc];
|
||||
const refreshGate = Promise.withResolvers<void>();
|
||||
const { picker, onPick } = createPicker({
|
||||
models: () => available,
|
||||
registry: { refresh: () => refreshGate.promise },
|
||||
});
|
||||
|
||||
picker.handleInput(DOWN); // highlight cc-model
|
||||
available = [modelAa, modelBb, modelCc];
|
||||
refreshGate.resolve();
|
||||
// Not a tuned delay: one zero-length tick drains the component's
|
||||
// refresh().then(...) continuation chain deterministically.
|
||||
await Bun.sleep(0);
|
||||
picker.handleInput("\n");
|
||||
expect(onPick.mock.calls[0]?.[0]?.id).toBe("cc-model");
|
||||
});
|
||||
|
||||
test("highlights and preselects the session's current model", () => {
|
||||
const models = [makeModel("test", "aa-model"), makeModel("test", "bb-model"), makeModel("test", "cc-model")];
|
||||
const { picker, onPick } = createPicker({
|
||||
models,
|
||||
scoped: true,
|
||||
picker: { currentSelector: "test/bb-model" },
|
||||
});
|
||||
|
||||
// The detail block tags the selected (= current) model.
|
||||
expect(normalize(picker.render(220))).toContain("current");
|
||||
|
||||
// Enter without navigation picks the preselected current model.
|
||||
picker.handleInput("\n");
|
||||
expect(onPick.mock.calls[0]?.[0]?.id).toBe("bb-model");
|
||||
});
|
||||
|
||||
test("Esc clears an active query first, then cancels", () => {
|
||||
const { picker, onCancel } = createPicker({ models: [makeModel("test", "test-model")], scoped: true });
|
||||
|
||||
picker.handleInput("q");
|
||||
picker.handleInput(ESC);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
picker.handleInput(ESC);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user