diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 69aa9acf6..abeb200e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/index.ts b/packages/coding-agent/src/modes/components/index.ts index 769bb9ddd..e1fe04035 100644 --- a/packages/coding-agent/src/modes/components/index.ts +++ b/packages/coding-agent/src/modes/components/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/components/model-browser.ts b/packages/coding-agent/src/modes/components/model-browser.ts index 1ae63ee24..84ae9f3aa 100644 --- a/packages/coding-agent/src/modes/components/model-browser.ts +++ b/packages/coding-agent/src/modes/components/model-browser.ts @@ -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; +/** + * 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, + autoCandidates: ReadonlyArray, +): 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(); + 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): 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(); const pushRole = (role: string) => { if (seen.has(role)) return; diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 8ddf6ec08..d662f8ed8 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -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; #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): 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(); - - 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)); diff --git a/packages/coding-agent/src/modes/components/model-picker.ts b/packages/coding-agent/src/modes/components/model-picker.ts new file mode 100644 index 000000000..be284792d --- /dev/null +++ b/packages/coding-agent/src/modes/components/model-picker.ts @@ -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; + #browser: ModelBrowser; + #configError: string | undefined; + + constructor( + tui: TUI, + settings: Settings, + registry: ModelRegistry, + scopedModels: ReadonlyArray, + 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; + 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; + } +} diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index e101bbc5f..e49ad1827 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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 { const succeeded = await this.#handleOAuthLogin(providerId); if (succeeded) { - this.#showModelHub({ mode: "roles", initialProviderId: providerId }); + this.#showModelHub({ initialProviderId: providerId }); } } diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 674d87435..553a14178 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -75,7 +75,6 @@ interface HubHarness { hub: ModelHubComponent; onAssign: ReturnType; onUnassign: ReturnType; - onPick: ReturnType; onLoginRequest: ReturnType; onCancel: ReturnType; 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(); - 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(); - 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 + // (` → …`) 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", () => { diff --git a/packages/coding-agent/test/model-picker.test.ts b/packages/coding-agent/test/model-picker.test.ts new file mode 100644 index 000000000..20de0b766 --- /dev/null +++ b/packages/coding-agent/test/model-picker.test.ts @@ -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; +} + +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(); + 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(); + 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); + }); +});