Files
oh-my-pi/packages/coding-agent/src/modes/components/model-picker.ts
T
can1357 992963f949 feat(coding-agent): allowed switching to models smaller than session context with compaction
- Make over-context models selectable in the model picker by graying them instead of disabling them.
- Trigger automatic session compaction with the current model prior to switching when an over-context model is chosen.
2026-08-01 20:59:24 +02:00

238 lines
9.2 KiB
TypeScript

/**
* Compact session-model picker (alt+p / `/switch`): a bottom-anchored
* floating overlay hosting just a {@link ModelBrowser} — no provider sidebar.
* Model entries switch the current session only; a search beginning with `@`
* exposes the configured ctrl+p quick roles.
*/
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 type { ResolvedRoleModel } from "../../session/agent-session";
import { theme } from "../theme/theme";
import {
buildBrowserItems,
ModelBrowser,
type ModelBrowserItem,
resolveRoleAssignments,
sortModelItems,
} from "./model-browser";
import type { ScopedModelItem } from "./model-hub";
import { bottomBorder, row, topBorder } from "./overlay-box";
import { resolveSegmentPalette } from "./segment-track";
export interface ModelPickerCallbacks {
/**
* A model was chosen for a session-only switch. `selector` is `provider/id`.
* `overContext` is true when the session transcript exceeds the model's
* context window — the host must compact before switching.
*/
onPick: (model: Model, selector: string, meta: { overContext: boolean }) => void;
/** A configured ctrl+p quick role was chosen. */
onPickRole?: (entry: ResolvedRoleModel) => void;
/** The picker was dismissed. */
onCancel: () => void;
}
export interface ModelPickerOptions {
/** Session token count; models with smaller context windows are grayed and compact-first on pick. */
currentContextTokens?: number;
/** `provider/id` of the session's active model; highlighted and preselected. */
currentSelector?: string;
/** Resolved role models in the same order used by the ctrl+p quick-role cycle. */
quickRoles?: ReadonlyArray<ResolvedRoleModel>;
/** Complete ctrl+p order, including unavailable roles, to preserve segment colors. */
quickRoleOrder?: ReadonlyArray<string>;
/** Active quick role, highlighted when the search begins with `@`. */
currentQuickRole?: 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 QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session";
const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · @ quick roles · Esc close";
const QUICK_ROLE_FOOTER_HINT = "↑/↓ roles · Enter apply role model · 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;
#currentSelector: string | undefined;
#currentQuickRoleSelector: string | undefined;
#modelItems: ModelBrowserItem[] = [];
#quickRoleItems: ModelBrowserItem[] = [];
#quickRoles = new Map<string, ResolvedRoleModel>();
#roleMode = false;
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.#currentSelector = options.currentSelector;
this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined;
this.#quickRoleItems = this.#buildQuickRoleItems(
options.quickRoles ?? [],
options.quickRoleOrder ?? options.quickRoles?.map(entry => entry.role) ?? [],
);
this.#browser = new ModelBrowser(settings, {
currentContextTokens: options.currentContextTokens,
markOverContext: true,
emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined),
});
this.#browser.onActivate = item => {
const quickRole = this.#quickRoles.get(item.selector);
if (quickRole) {
callbacks.onPickRole?.(quickRole);
return;
}
callbacks.onPick(item.model, item.selector, { overContext: this.#browser.isOverContext(item) });
};
this.#browser.onCancel = () => callbacks.onCancel();
this.#browser.onQueryChange = query => this.#syncItemsForQuery(query);
// 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 model items and role chips from the registry's in-memory state. */
#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() ?? [];
this.#modelItems = buildBrowserItems(models);
sortModelItems(this.#modelItems, { roles, mruOrder });
this.#browser.setRoles(roles);
this.#browser.setMruOrder(mruOrder);
this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
this.#syncItemsForQuery(this.#browser.query, true);
}
/** Build virtual `@role` rows, colored by their ctrl+p segment position. */
#buildQuickRoleItems(
quickRoles: ReadonlyArray<ResolvedRoleModel>,
quickRoleOrder: ReadonlyArray<string>,
): ModelBrowserItem[] {
const order = quickRoleOrder.length > 0 ? quickRoleOrder : quickRoles.map(entry => entry.role);
const palette = resolveSegmentPalette(order.length);
return quickRoles.map((entry, index) => {
const selector = `@${entry.role}`;
this.#quickRoles.set(selector, entry);
const orderIndex = order.indexOf(entry.role);
return {
provider: "",
id: selector,
model: entry.model,
selector,
labelColor: palette[(orderIndex >= 0 ? orderIndex : index) % palette.length],
};
});
}
/** Switch browser content only when a leading `@` changes the search mode. */
#syncItemsForQuery(query: string, refresh = false): void {
const roleMode = query.startsWith("@");
const modeChanged = roleMode !== this.#roleMode;
if (!modeChanged && !refresh) return;
this.#roleMode = roleMode;
this.#browser.setShowProvider(!roleMode);
this.#browser.setMarkOverContext(!roleMode);
this.#browser.setPreserveQueryOrder(roleMode);
const currentSelector = roleMode ? this.#currentQuickRoleSelector : this.#currentSelector;
this.#browser.setCurrentSelector(currentSelector);
this.#browser.setItems(roleMode ? this.#quickRoleItems : this.#modelItems);
if (modeChanged && currentSelector) {
this.#browser.selectSelector(currentSelector);
}
}
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", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : 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", this.#roleMode ? QUICK_ROLE_FOOTER_HINT : FOOTER_HINT), width));
out.push(bottomBorder(width));
return out;
}
}