From e4203f014a0f0da5b813e54e99ba1657ee08c613 Mon Sep 17 00:00:00 2001 From: jiachen Date: Wed, 23 Sep 2026 21:12:38 +0800 Subject: [PATCH] feat(tui): add model favorites to the /model picker A provider can expose many models while users switch between only a few. Let users star those models so they can reach them without scrolling through every provider group. - Press Ctrl+S in /model to star or unstar the focused model. Starred models appear in a Favorites group at the top, in the order they were added, and keep a star marker in their provider group. - When the current model is a favorite, the picker opens with focus on its Favorites row. Search filters the Favorites group as well. - Favorites identify a model only (provider and model ID); effort, thinking and context still follow the normal selection flow. - Store favorites in the local runtime preferences table under `model-favorites`. Malformed values degrade to an empty or partial list. Favorites missing from the catalog are hidden, and are pruned on the next toggle only when the catalog was read successfully. - The picker updates immediately and rolls back with an inline notice if saving fails. Hosts without a favorites store hide the shortcut. Only the TUI uses this capability for now; Desktop and ACP are unchanged. --- .../process-local-application-contract.ts | 6 + .../session/process-local-application.ts | 58 +++++-- .../local-runtime-v2/src/local/cli-service.ts | 13 ++ .../catalog/model-favorites.test.ts | 141 ++++++++++++++++ .../model-system/catalog/model-favorites.ts | 97 +++++++++++ .../src/service/model-system/index.ts | 1 + packages/local-runtime-v2/src/services.ts | 2 + packages/tui/src/runtime/adapter.ts | 3 + .../src/runtime/adapters/product-access.ts | 11 ++ packages/tui/src/runtime/port.ts | 2 + .../tui/controller/product/feature-flow.ts | 10 ++ packages/tui/src/tui/features/model/picker.ts | 155 +++++++++++++++--- packages/tui/src/types/runtime-models.ts | 3 + .../tui/test/unit/tui-model-picker.test.ts | 145 ++++++++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 1 + 16 files changed, 614 insertions(+), 36 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.test.ts create mode 100644 packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.ts diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index ff803b1a..fa90ae1f 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -244,6 +244,12 @@ export interface LocalRuntimeApplication { variant?: string; sessionId?: string; }): Promise; + /** Adds or removes a model favorite; absent when the host has no preference store. */ + setFavorite?(input: { + providerId: string; + modelId: string; + favorite: boolean; + }): Promise; }; readonly modelProviders?: { listProviderPresets(): Promise; diff --git a/packages/local-runtime-v2/src/application/session/process-local-application.ts b/packages/local-runtime-v2/src/application/session/process-local-application.ts index 1a2f877d..b5da0eee 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application.ts @@ -1,12 +1,15 @@ import type { GlobalEvent } from '@mavis/shared/global-events'; import { isLegacyManagedMinimaxProvider } from '@mavis/config'; -import type { - CodexOAuthManager, - LocalModelProviderService, - ModelSystemOwner, - ModelProviderView, - UserModelInputView, +import { + annotateModelFavorites, + modelFavoriteRefs, + type CodexOAuthManager, + type LocalModelProviderService, + type ModelFavoritesPreference, + type ModelSystemOwner, + type ModelProviderView, + type UserModelInputView, } from '../../service/model-system/index.js'; import { watchGlobalEvents, watchProcessEvents } from '../events.js'; import type { ModelProviderApplication } from './model-provider-application.js'; @@ -35,6 +38,7 @@ export interface ProcessLocalApplicationOptions { readonly providers: LocalModelProviderService; readonly listProviderPresets: ModelSystemOwner['listProviderPresets']; readonly oauth: Pick; + readonly favorites?: Pick; }; readonly peripherals: Required< Pick< @@ -124,10 +128,7 @@ export function createProcessLocalApplication( }, ...(options.instructions ? { instructions: options.instructions } : {}), configuration: options.peripherals.configuration, - models: { - list: (request = {}) => options.modelProvider.application.list(request), - select: (request) => options.modelProvider.application.select(request), - }, + models: createModelsApplication(options.modelProvider), modelProviders: { listProviderPresets: () => options.modelProvider.listProviderPresets(), getCodexOAuthStatus: async () => options.modelProvider.oauth.getStatus(), @@ -218,3 +219,40 @@ function toUserModelInputs( : {}), })); } + +function createModelsApplication( + modelProvider: ProcessLocalApplicationOptions['modelProvider'], +): NonNullable { + const favorites = modelProvider.favorites; + const list = (request: { sessionId?: string } = {}) => + modelProvider.application.list(request); + if (!favorites) { + return { list, select: (request) => modelProvider.application.select(request) }; + } + return { + list: async (request = {}) => { + const models = await list(request); + // A broken preference row must never hide the catalog. + let saved: ReturnType = []; + try { + saved = favorites.list(); + } catch { + saved = []; + } + return annotateModelFavorites(models, saved); + }, + select: (request) => modelProvider.application.select(request), + setFavorite: async (request) => { + // Prune stale favorites only against a catalog we actually read. + let catalog: ReturnType | undefined; + try { + catalog = modelFavoriteRefs(await list({})); + } catch { + catalog = undefined; + } + if (catalog && catalog.length === 0) catalog = undefined; + favorites.set(request, catalog); + return true; + }, + }; +} diff --git a/packages/local-runtime-v2/src/local/cli-service.ts b/packages/local-runtime-v2/src/local/cli-service.ts index d365a005..64bed1d5 100644 --- a/packages/local-runtime-v2/src/local/cli-service.ts +++ b/packages/local-runtime-v2/src/local/cli-service.ts @@ -657,6 +657,19 @@ export class CliService { return this.requireCapability("models", "Model").select(input); } + /** Resolves false when the host has no model-favorite store. */ + async setModelFavorite( + input: Parameters< + NonNullable< + NonNullable["setFavorite"] + > + >[0], + ): Promise { + const models = this.requireCapability("models", "Model"); + if (!models.setFavorite) return false; + return models.setFavorite(input); + } + listUserModelProviders() { return this.requireCapability( "modelProviders", diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.test.ts b/packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.test.ts new file mode 100644 index 00000000..2ba3c460 --- /dev/null +++ b/packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createProcessLocalApplication } from "../../../application/session/process-local-application.js"; +import { DatabaseClient } from "../../../infra/db/client.js"; +import { initializeDatabase } from "../../../infra/db/initialize.js"; +import { upsertPreferenceValue } from "../../../infra/db/preference-values.js"; +import { annotateModelFavorites, ModelFavoritesPreference } from "./model-favorites.js"; + +const cleanup: Array<() => Promise | void> = []; +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); + +async function openDatabase() { + const dataDir = await mkdtemp(join(tmpdir(), "model-favorites-")); + cleanup.push(() => rm(dataDir, { recursive: true, force: true })); + const database = new DatabaseClient({ dataDir }); + cleanup.push(() => database.close()); + await initializeDatabase({ database, dataDir }); + return database; +} + +const m3 = { providerId: "minimax", modelId: "MiniMax-M3" }; +const kimi = { providerId: "kimi", modelId: "kimi-k3" }; +const deepseek = { providerId: "deepseek", modelId: "deepseek-v4-pro" }; + +describe("ModelFavoritesPreference", () => { + it("appends new favorites last and survives a reopen", async () => { + const database = await openDatabase(); + const favorites = new ModelFavoritesPreference(database.db); + expect(favorites.list()).toEqual([]); + favorites.set({ ...kimi, favorite: true }); + favorites.set({ ...m3, favorite: true }); + favorites.set({ ...kimi, favorite: true }); // idempotent, keeps position + expect(favorites.list()).toEqual([kimi, m3]); + favorites.set({ ...kimi, favorite: false }); + expect(new ModelFavoritesPreference(database.db).list()).toEqual([m3]); + }); + + it("degrades malformed stored values instead of throwing", async () => { + const database = await openDatabase(); + const favorites = new ModelFavoritesPreference(database.db); + upsertPreferenceValue(database.db, "model-favorites", { not: "a list" }); + expect(favorites.list()).toEqual([]); + upsertPreferenceValue(database.db, "model-favorites", [ + "minimax/MiniMax-M3", + { providerId: "kimi" }, + { providerId: " kimi ", modelId: " kimi-k3 " }, + { ...kimi }, + null, + ]); + expect(favorites.list()).toEqual([kimi]); + // An empty id is ignored rather than stored. + expect(favorites.set({ providerId: "", modelId: "x", favorite: true })).toEqual([kimi]); + }); + + it("keeps stale favorites until a write prunes them against the catalog", async () => { + const database = await openDatabase(); + const favorites = new ModelFavoritesPreference(database.db); + favorites.set({ ...deepseek, favorite: true }); + favorites.set({ ...kimi, favorite: true }); + // deepseek left the catalog: reading keeps it, the next write drops it. + expect(favorites.list()).toEqual([deepseek, kimi]); + favorites.set({ ...m3, favorite: true }, [m3, kimi]); + expect(favorites.list()).toEqual([kimi, m3]); + }); + + it("annotates catalog rows with their favorite position without reordering", () => { + const rows = annotateModelFavorites( + [{ ...m3, selected: true }, { ...kimi }, { ...deepseek }], + [deepseek, m3], + ); + expect(rows).toEqual([ + { ...m3, selected: true, favorite: true, favoriteOrder: 1 }, + { ...kimi }, + { ...deepseek, favorite: true, favoriteOrder: 0 }, + ]); + }); +}); + +describe("process-local model favorites", () => { + function application( + list: () => Promise, + favorites?: ModelFavoritesPreference, + ) { + return createProcessLocalApplication({ + eventBus: { subscribe: vi.fn(() => () => undefined) }, + skills: {} as never, + plugins: {} as never, + workspace: {} as never, + plan: {} as never, + peripherals: {} as never, + modelProvider: { + application: { list, select: vi.fn(async () => true) }, + providers: {} as never, + listProviderPresets: vi.fn(async () => []), + oauth: {} as never, + ...(favorites ? { favorites } : {}), + }, + }); + } + + it("marks favorites in the listed catalog and prunes only against a readable catalog", async () => { + const database = await openDatabase(); + const favorites = new ModelFavoritesPreference(database.db); + favorites.set({ ...deepseek, favorite: true }); + let catalog: () => Promise = async () => [m3, kimi]; + const app = application(() => catalog(), favorites); + + // The catalog read fails: the stale favorite must survive the write. + catalog = async () => { + throw new Error("catalog offline"); + }; + await expect(app.models?.setFavorite?.({ ...kimi, favorite: true })).resolves.toBe(true); + expect(favorites.list()).toEqual([deepseek, kimi]); + + catalog = async () => [m3, kimi]; + await app.models?.setFavorite?.({ ...m3, favorite: true }); + expect(favorites.list()).toEqual([kimi, m3]); + await expect(app.models?.list()).resolves.toEqual([ + { ...m3, favorite: true, favoriteOrder: 1 }, + { ...kimi, favorite: true, favoriteOrder: 0 }, + ]); + }); + + it("lists the plain catalog when the preference store fails or is absent", async () => { + const broken = { + list: () => { + throw new Error("database is locked"); + }, + set: vi.fn(), + } as unknown as ModelFavoritesPreference; + await expect(application(async () => [m3], broken).models?.list()).resolves.toEqual([m3]); + const withoutStore = application(async () => [m3]); + await expect(withoutStore.models?.list()).resolves.toEqual([m3]); + expect(withoutStore.models?.setFavorite).toBeUndefined(); + }); +}); diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.ts b/packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.ts new file mode 100644 index 00000000..f58a1263 --- /dev/null +++ b/packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.ts @@ -0,0 +1,97 @@ +import type { AppDb } from '../../../infra/db/client.js'; +import { readPreferenceValue, upsertPreferenceValue } from '../../../infra/db/preference-values.js'; + +const MODEL_FAVORITES_PREFERENCE_KEY = 'model-favorites'; + +export interface ModelFavoriteRef { + readonly providerId: string; + readonly modelId: string; +} + +export interface SetModelFavoriteInput extends ModelFavoriteRef { + readonly favorite: boolean; +} + +/** + * User-starred models, stored in insertion order like pins: a new favorite is + * appended to the end. Entries only identify a model; effort, thinking and + * context stay with the picker's normal selection flow. + * + * Malformed stored values degrade to an empty or partial list instead of + * failing, and favorites whose model is no longer in the catalog are kept until + * the next write, where they are pruned against the catalog passed in. + */ +export class ModelFavoritesPreference { + constructor(private readonly db: AppDb) {} + + list(): ModelFavoriteRef[] { + return sanitize(readPreferenceValue(this.db, MODEL_FAVORITES_PREFERENCE_KEY)); + } + + /** + * @param catalog Models currently listed; when provided, stale favorites + * outside it are dropped. Omit it to leave existing entries untouched. + */ + set(input: SetModelFavoriteInput, catalog?: readonly ModelFavoriteRef[]): ModelFavoriteRef[] { + const target = toRef(input); + if (!target) return this.list(); + const known = catalog ? new Set(catalog.map(refKey)) : undefined; + const current = this.list(); + const exists = current.some((entry) => refKey(entry) === refKey(target)); + const next = current.filter( + (entry) => + (refKey(entry) === refKey(target) ? input.favorite : true) && + (!known || known.has(refKey(entry)) || refKey(entry) === refKey(target)), + ); + // Starring an existing favorite is idempotent and keeps its position. + if (input.favorite && !exists) next.push(target); + upsertPreferenceValue(this.db, MODEL_FAVORITES_PREFERENCE_KEY, next); + return next; + } +} + +/** Marks catalog rows with their favorite position without reordering them. */ +export function annotateModelFavorites( + models: readonly T[], + favorites: readonly ModelFavoriteRef[], +): T[] { + const order = new Map(favorites.map((entry, index) => [refKey(entry), index])); + return models.map((model) => { + const ref = toRef(model); + const index = ref ? order.get(refKey(ref)) : undefined; + return index === undefined ? model : { ...model, favorite: true, favoriteOrder: index }; + }); +} + +export function modelFavoriteRefs(models: readonly unknown[]): ModelFavoriteRef[] { + return models.flatMap((model) => { + const ref = toRef(model); + return ref ? [ref] : []; + }); +} + +function sanitize(value: unknown): ModelFavoriteRef[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: ModelFavoriteRef[] = []; + for (const item of value) { + const ref = toRef(item); + if (!ref || seen.has(refKey(ref))) continue; + seen.add(refKey(ref)); + result.push(ref); + } + return result; +} + +function toRef(value: unknown): ModelFavoriteRef | undefined { + if (!value || typeof value !== 'object') return undefined; + const { providerId, modelId } = value as Record; + if (typeof providerId !== 'string' || typeof modelId !== 'string') return undefined; + const provider = providerId.trim(); + const model = modelId.trim(); + return provider && model ? { providerId: provider, modelId: model } : undefined; +} + +function refKey(ref: ModelFavoriteRef): string { + return `${ref.providerId}\u0000${ref.modelId}`; +} diff --git a/packages/local-runtime-v2/src/service/model-system/index.ts b/packages/local-runtime-v2/src/service/model-system/index.ts index b9841e45..f8d9112f 100644 --- a/packages/local-runtime-v2/src/service/model-system/index.ts +++ b/packages/local-runtime-v2/src/service/model-system/index.ts @@ -48,6 +48,7 @@ export * from './catalog/catalog.js'; export * from './catalog/config-fingerprint.js'; export * from './catalog/list-models.js'; export * from './catalog/model-cache.js'; +export * from './catalog/model-favorites.js'; export * from './catalog/model-selection.js'; export * from './catalog/provider-views.js'; export * from './connectivity/discover-models.js'; diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index 5276fed5..9c63e89e 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -143,6 +143,7 @@ import { } from "./service/miniapp/index.js"; import { createLocalModelSystemConfigPort, + ModelFavoritesPreference, resolveLocalRuntimeModelKey, type ModelSystemOwner, } from "./service/model-system/index.js"; @@ -530,6 +531,7 @@ export async function createRuntimeServices( providers: modelSystem.providers, listProviderPresets: modelSystem.listProviderPresets, oauth: modelSystem.oauth, + favorites: new ModelFavoritesPreference(options.db), }, }); if (owners.cron) diff --git a/packages/tui/src/runtime/adapter.ts b/packages/tui/src/runtime/adapter.ts index 31662c66..4a77e509 100644 --- a/packages/tui/src/runtime/adapter.ts +++ b/packages/tui/src/runtime/adapter.ts @@ -446,6 +446,9 @@ export class TuiRuntimeAdapter implements TuiRuntime { ): Promise { return this.productAccess.selectSessionModel(model, sessionId); } + setModelFavorite(model: TuiModelSelection, favorite: boolean): Promise { + return this.productAccess.setModelFavorite(model, favorite); + } listUserModelProviders() { return this.productAccess.listUserModelProviders(); } diff --git a/packages/tui/src/runtime/adapters/product-access.ts b/packages/tui/src/runtime/adapters/product-access.ts index f7738c86..32ca3fa9 100644 --- a/packages/tui/src/runtime/adapters/product-access.ts +++ b/packages/tui/src/runtime/adapters/product-access.ts @@ -109,6 +109,17 @@ export class TuiProductAccess { return this.selectSessionModel(model, sessionId); } + setModelFavorite( + model: TuiModelSelection, + favorite: boolean, + ): Promise { + return this.context.service("model.favorite").setModelFavorite({ + providerId: model.providerId, + modelId: model.modelId, + favorite, + }); + } + selectSessionModel( model: TuiModelSelection, sessionId: string, diff --git a/packages/tui/src/runtime/port.ts b/packages/tui/src/runtime/port.ts index cda4fe68..a7e9484a 100644 --- a/packages/tui/src/runtime/port.ts +++ b/packages/tui/src/runtime/port.ts @@ -273,6 +273,8 @@ export interface TuiConfigurationPort extends McodeProviderRuntimePort { listModels(sessionId?: string): Promise; selectModel(model: TuiModelSelection, sessionId?: string): Promise; selectSessionModel(model: TuiModelSelection, sessionId: string): Promise; + /** Stars or unstars a model; resolves false when favorites are unavailable. */ + setModelFavorite?(model: TuiModelSelection, favorite: boolean): Promise; } export interface TuiAccountStatusOptions { diff --git a/packages/tui/src/tui/controller/product/feature-flow.ts b/packages/tui/src/tui/controller/product/feature-flow.ts index 6bf39551..1edc8944 100644 --- a/packages/tui/src/tui/controller/product/feature-flow.ts +++ b/packages/tui/src/tui/controller/product/feature-flow.ts @@ -721,6 +721,16 @@ export class TuiFeatureFlow { this.closeModelPicker(); void this.showProviderOnboarding(); }, + ...(this.options.runtime.setModelFavorite + ? { + // The picker updates the row first and rolls back on failure. + onToggleFavorite: (model: TuiModel, favorite: boolean) => + this.options.runtime.setModelFavorite?.( + { providerId: model.providerId, modelId: model.modelId }, + favorite, + ) ?? false, + } + : {}), onDeleteProvider: async (providerId) => { if ( this.modelPicker !== picker || diff --git a/packages/tui/src/tui/features/model/picker.ts b/packages/tui/src/tui/features/model/picker.ts index 5acf03d1..e4080bd4 100644 --- a/packages/tui/src/tui/features/model/picker.ts +++ b/packages/tui/src/tui/features/model/picker.ts @@ -36,6 +36,9 @@ const MODEL_CONTROL_ROWS = 3; type ModelKey = string; const CONNECT_CODEX_ITEM_VALUE = '\u0000connect-codex'; const ADD_PROVIDER_ITEM_VALUE = '\u0000add-provider'; +/** Favorite rows repeat a provider row, so they need a distinct list value. */ +const FAVORITE_ROW_PREFIX = '\u0000favorite\u0000'; +const FAVORITE_UPDATE_FAILED = "Couldn't update favorites. Press ctrl+s to retry."; interface ModelGroup { readonly label: string; @@ -54,6 +57,9 @@ export class TuiModelPicker implements Component, Focusable { private readonly contextDrafts = new Map(); private readonly effortDrafts = new Map(); private readonly modelByKey = new Map(); + /** Starred models in the order they were added; new favorites go last. */ + private favoriteKeys: ModelKey[]; + private favoriteStatus: string | undefined; private deletingProviderId: string | undefined; private deleteStatus: { readonly tone: 'info' | 'error'; readonly text: string } | undefined; private busy = false; @@ -73,12 +79,18 @@ export class TuiModelPicker implements Component, Focusable { }; onAddProvider?: () => void; onDeleteProvider?: (providerId: string) => Promise; + /** Persists a favorite toggle; `false` or a rejection rolls the row back. */ + onToggleFavorite?: (model: TuiModel, favorite: boolean) => Promise | boolean | void; requestRender?: () => void; } = {}, /** Think effort already stored for the current Session, when known. */ private readonly selectedEffort?: string, initialQuery = '', ) { + this.favoriteKeys = models + .filter((model) => model.favorite) + .sort((a, b) => (a.favoriteOrder ?? Infinity) - (b.favoriteOrder ?? Infinity)) + .map(modelKey); const selectedModel = models.find((model) => model.selected); this.selectedModelKey = selectedModel ? modelKey(selectedModel) : undefined; this.focusBeforeSearchKey = this.selectedModelKey; @@ -110,6 +122,10 @@ export class TuiModelPicker implements Component, Focusable { } const model = this.focusedModel(); + if (model && this.availability.onToggleFavorite && matchesKey(data, Key.ctrl('s'))) { + this.toggleFavorite(model); + return; + } if ( model && isCustomProviderModel(model) && @@ -194,6 +210,9 @@ export class TuiModelPicker implements Component, Focusable { ...(layout.bodyHeight >= 3 && this.availability.unavailableHint ? [chalk.hex(colors.warning)(sanitizeTerminalText(this.availability.unavailableHint))] : []), + ...(layout.bodyHeight >= 3 && this.favoriteStatus + ? [chalk.hex(colors.warning)(this.favoriteStatus)] + : []), ...(layout.bodyHeight >= 2 ? [`${searchPrompt}${search}`] : []), ...(layout.bodyHeight >= 4 && this.visibleModels().length === 0 && @@ -224,7 +243,49 @@ export class TuiModelPicker implements Component, Focusable { private focusedModel(): TuiModel | undefined { const item = this.list.getSelectedItem(); - return item ? this.modelByKey.get(item.value) : undefined; + return item ? this.modelByKey.get(rowModelKey(item.value)) : undefined; + } + + private isFavorite(model: TuiModel): boolean { + return this.favoriteKeys.includes(modelKey(model)); + } + + private toggleFavorite(model: TuiModel): void { + const onToggle = this.availability.onToggleFavorite; + if (!onToggle) return; + const key = modelKey(model); + const previousIndex = this.favoriteKeys.indexOf(key); + const favorite = previousIndex < 0; + this.favoriteKeys = favorite + ? [...this.favoriteKeys, key] + : this.favoriteKeys.filter((candidate) => candidate !== key); + this.favoriteStatus = undefined; + // Unstarring from the Favorites group keeps focus on the same model. + if (!favorite && this.focusedModelKey === favoriteRowValue(key)) this.focusedModelKey = key; + this.list = this.createList(); + this.availability.requestRender?.(); + + const rollback = () => { + const current = this.favoriteKeys.filter((candidate) => candidate !== key); + if (!favorite) current.splice(Math.min(previousIndex, current.length), 0, key); + this.favoriteKeys = current; + this.favoriteStatus = FAVORITE_UPDATE_FAILED; + this.list = this.createList(); + this.availability.requestRender?.(); + }; + let result: ReturnType; + try { + result = onToggle(model, favorite); + } catch { + rollback(); + return; + } + void Promise.resolve(result).then( + (saved) => { + if (saved === false) rollback(); + }, + rollback, + ); } private contextChoice(model: TuiModel): number | undefined { @@ -279,21 +340,43 @@ export class TuiModelPicker implements Component, Focusable { this.modelByKey.clear(); for (const model of this.models) this.modelByKey.set(modelKey(model), model); - const items = groups.flatMap((group) => - group.models.map((model) => { - const key = modelKey(model); - const unavailable = Boolean(this.availability.isUnavailable?.(model)); - return { - value: key, - label: `${model.selected ? '● ' : ''}${unavailable ? '[login] ' : ''}${sanitizeTerminalText(model.displayName ?? model.modelId)}`, - description: formatModelDescription( - { ...model, contextLimit: this.contextChoice(model) }, - unavailable, + const row = (model: TuiModel, value: string, groupLabel: string, star: boolean) => { + const unavailable = Boolean(this.availability.isUnavailable?.(model)); + return { + value, + label: `${model.selected ? '● ' : ''}${star ? '★ ' : ''}${unavailable ? '[login] ' : ''}${sanitizeTerminalText(model.displayName ?? model.modelId)}`, + description: formatModelDescription( + { ...model, contextLimit: this.contextChoice(model) }, + unavailable, + ), + groupLabel, + }; + }; + const visibleKeys = new Set(visible.map(modelKey)); + const favorites = this.favoriteKeys.flatMap((key) => { + const model = visibleKeys.has(key) ? this.modelByKey.get(key) : undefined; + return model ? [model] : []; + }); + const items = [ + ...favorites.map((model) => + row( + model, + favoriteRowValue(modelKey(model)), + `★ Favorites · ${favorites.length}`, + false, + ), + ), + ...groups.flatMap((group) => + group.models.map((model) => + row( + model, + modelKey(model), + `${sanitizeTerminalText(group.label)} · ${group.models.length}`, + this.isFavorite(model), ), - groupLabel: `${sanitizeTerminalText(group.label)} · ${group.models.length}`, - }; - }), - ); + ), + ), + ]; items.push({ value: ADD_PROVIDER_ITEM_VALUE, label: '+ Add 3rd-party provider…', @@ -324,7 +407,7 @@ export class TuiModelPicker implements Component, Focusable { this.availability.onAddProvider?.(); return; } - const model = this.modelByKey.get(item.value); + const model = this.modelByKey.get(rowModelKey(item.value)); if (!model) return; if (this.availability.isUnavailable?.(model)) { this.availability.onUnavailable?.(model); @@ -343,16 +426,23 @@ export class TuiModelPicker implements Component, Focusable { }; list.onCancel = this.onCancel; - const preferredIndex = this.focusedModelKey - ? visible.findIndex((model) => modelKey(model) === this.focusedModelKey) - : visible.findIndex((model) => modelKey(model) === this.selectedModelKey); + // Explicit focus is tracked by row value. Otherwise use the model's first + // row, so the selected favorite opens on its Favorites row at the top. + const modelRows = items.filter((item) => this.modelByKey.has(rowModelKey(item.value))); + const target = this.focusedModelKey ?? this.selectedModelKey; + let preferredIndex = this.focusedModelKey + ? modelRows.findIndex((item) => item.value === this.focusedModelKey) + : -1; + if (preferredIndex < 0 && target) { + preferredIndex = modelRows.findIndex( + (item) => rowModelKey(item.value) === rowModelKey(target), + ); + } if (preferredIndex >= 0) { list.setSelectedIndex(preferredIndex); - this.focusedModelKey = visible[preferredIndex] - ? modelKey(visible[preferredIndex]) - : undefined; - } else if (visible[0]) { - this.focusedModelKey = modelKey(visible[0]); + this.focusedModelKey = modelRows[preferredIndex]?.value; + } else if (modelRows[0]) { + this.focusedModelKey = modelRows[0].value; } return list; } @@ -374,9 +464,14 @@ export class TuiModelPicker implements Component, Focusable { const switchable = model?.thinkingConfig?.mode === 'switchable'; const contextHint = model && contextWindowOptions(model).length > 1 ? ' · tab context' : ''; const deleteHint = - model && isCustomProviderModel(model) && this.availability.onDeleteProvider + (model && this.availability.onToggleFavorite + ? this.isFavorite(model) + ? ' · ctrl+s unfavorite' + : ' · ctrl+s favorite' + : '') + + (model && isCustomProviderModel(model) && this.availability.onDeleteProvider ? ' · ctrl+d delete provider' - : ''; + : ''); if (supportsTuiEffort(model)) { return `↑↓ select · type to search · ←/→ effort · enter apply${contextHint}${deleteHint} · esc cancel`; } @@ -535,6 +630,14 @@ function modelKey(model: TuiModel): ModelKey { return `${model.providerId}\u0000${model.modelId}`; } +function favoriteRowValue(key: ModelKey): string { + return `${FAVORITE_ROW_PREFIX}${key}`; +} + +function rowModelKey(value: string): ModelKey { + return value.startsWith(FAVORITE_ROW_PREFIX) ? value.slice(FAVORITE_ROW_PREFIX.length) : value; +} + function isCustomProviderModel(model: TuiModel): boolean { return model.providerSource === 'custom_provider'; } diff --git a/packages/tui/src/types/runtime-models.ts b/packages/tui/src/types/runtime-models.ts index 4169bcd5..523c1602 100644 --- a/packages/tui/src/types/runtime-models.ts +++ b/packages/tui/src/types/runtime-models.ts @@ -180,6 +180,9 @@ export interface TuiModel { defaultEffort?: string; /** Runtime projects the saved global or Session selection onto the selected row. */ thinking?: { effort?: string }; + /** Present on starred models; `favoriteOrder` ascends in the order they were added. */ + favorite?: boolean; + favoriteOrder?: number; providerName?: string; providerSource?: string; providerKind?: string; diff --git a/packages/tui/test/unit/tui-model-picker.test.ts b/packages/tui/test/unit/tui-model-picker.test.ts index 0042751a..c8d47bd6 100644 --- a/packages/tui/test/unit/tui-model-picker.test.ts +++ b/packages/tui/test/unit/tui-model-picker.test.ts @@ -971,3 +971,148 @@ describe("TuiModelPicker", () => { ); }); }); + +describe("TuiModelPicker favorites", () => { + const CTRL_S = "\x13"; + const DOWN = "\x1b[B"; + const catalog = () => [ + { providerId: "minimax", providerName: "MiniMax", modelId: "MiniMax-M3" }, + { providerId: "minimax", providerName: "MiniMax", modelId: "MiniMax-M2.7" }, + { + providerId: "deepseek", + providerName: "DeepSeek", + modelId: "deepseek-v4-pro", + favorite: true, + favoriteOrder: 1, + }, + { + providerId: "kimi", + providerName: "Kimi", + modelId: "kimi-k3", + favorite: true, + favoriteOrder: 0, + selected: true, + }, + ]; + const lines = (picker: TuiModelPicker) => + stripAnsi(picker.render(110).join("\n")).split("\n"); + const indexOf = (rendered: string[], text: string) => + rendered.findIndex((line) => line.includes(text)); + + it("lists favorites first in added order and keeps a star on their provider rows", () => { + const onSelect = vi.fn(); + const picker = new TuiModelPicker(catalog(), onSelect, vi.fn(), { + onToggleFavorite: vi.fn(async () => true), + }); + const rendered = lines(picker); + const favorites = indexOf(rendered, "★ Favorites · 2"); + expect(favorites).toBeGreaterThanOrEqual(0); + expect(favorites).toBeLessThan(indexOf(rendered, "MiniMax · 2")); + const kimi = indexOf(rendered, "● kimi-k3"); + const deepseek = indexOf(rendered, "deepseek-v4-pro"); + expect(kimi).toBeGreaterThan(favorites); + expect(deepseek).toBeGreaterThan(kimi); + expect(rendered.join("\n")).toContain("● ★ kimi-k3"); + expect(rendered.join("\n")).toContain("★ deepseek-v4-pro"); + expect(rendered.join("\n")).toContain("ctrl+s unfavorite"); + + // The selected model is focused on its Favorites row and applies as itself. + const focused = rendered.findIndex((line) => line.includes("› ")); + expect(focused).toBe(kimi); + expect(focused).toBeLessThan(indexOf(rendered, "MiniMax · 2")); + picker.handleInput("\r"); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ providerId: "kimi", modelId: "kimi-k3" }), + ); + }); + + it("appends a new favorite last and unstars from the group without losing focus", async () => { + const onToggleFavorite = vi.fn(async () => true); + const onSelect = vi.fn(); + const picker = new TuiModelPicker(catalog(), onSelect, vi.fn(), { onToggleFavorite }); + + picker.handleInput("M2.7"); + expect(lines(picker).join("\n")).toContain("ctrl+s favorite"); + picker.handleInput(CTRL_S); + expect(onToggleFavorite).toHaveBeenLastCalledWith( + expect.objectContaining({ modelId: "MiniMax-M2.7" }), + true, + ); + expect(lines(picker).join("\n")).toContain("Search: M2.7"); + for (let index = 0; index < 4; index += 1) picker.handleInput("\x7f"); + + let rendered = lines(picker); + expect(rendered.join("\n")).toContain("★ Favorites · 3"); + const group = rendered.slice(indexOf(rendered, "★ Favorites · 3"), indexOf(rendered, "MiniMax · 2")); + expect(group.findIndex((line) => line.includes("MiniMax-M2.7"))).toBeGreaterThan( + group.findIndex((line) => line.includes("deepseek-v4-pro")), + ); + + // Focus starts on the selected model's Favorites row; move to deepseek and unstar. + picker.handleInput(DOWN); + picker.handleInput(CTRL_S); + await Promise.resolve(); + expect(onToggleFavorite).toHaveBeenLastCalledWith( + expect.objectContaining({ modelId: "deepseek-v4-pro" }), + false, + ); + rendered = lines(picker); + expect(rendered.join("\n")).toContain("★ Favorites · 2"); + expect(rendered.join("\n")).not.toContain("★ deepseek-v4-pro"); + picker.handleInput("\r"); + expect(onSelect).toHaveBeenLastCalledWith( + expect.objectContaining({ providerId: "deepseek", modelId: "deepseek-v4-pro" }), + ); + }); + + it("filters the Favorites group with the search query", () => { + const picker = new TuiModelPicker(catalog(), vi.fn(), vi.fn(), { + onToggleFavorite: vi.fn(), + }); + picker.handleInput("deepseek"); + const rendered = lines(picker).join("\n"); + expect(rendered).toContain("★ Favorites · 1"); + expect(rendered).not.toContain("kimi-k3"); + }); + + it("rolls back and explains a failed save without throwing", async () => { + const requestRender = vi.fn(); + const outcomes: Array<() => Promise> = [ + async () => false, + async () => { + throw new Error("disk full"); + }, + ]; + const onToggleFavorite = vi.fn(() => outcomes.shift()!()); + const picker = new TuiModelPicker(catalog(), vi.fn(), vi.fn(), { + onToggleFavorite, + requestRender, + }); + + picker.handleInput(CTRL_S); // unstar kimi → resolves false + await new Promise((resolve) => setTimeout(resolve, 0)); + let rendered = lines(picker).join("\n"); + expect(rendered).toContain("★ Favorites · 2"); + expect(rendered).toContain("Couldn't update favorites."); + + picker.handleInput("M3"); + picker.handleInput(CTRL_S); // star MiniMax-M3 → rejects + await new Promise((resolve) => setTimeout(resolve, 0)); + rendered = lines(picker).join("\n"); + expect(rendered).not.toContain("★ MiniMax-M3"); + expect(rendered).toContain("Couldn't update favorites."); + for (let index = 0; index < 2; index += 1) picker.handleInput("\x7f"); + // The rolled-back unstar keeps kimi first, ahead of deepseek. + rendered = lines(picker).join("\n"); + expect(rendered.indexOf("kimi-k3")).toBeLessThan(rendered.indexOf("deepseek-v4-pro")); + }); + + it("hides the toggle when favorites are unavailable and keeps ctrl+s out of search", () => { + const picker = new TuiModelPicker(catalog(), vi.fn(), vi.fn()); + picker.handleInput(CTRL_S); + const rendered = lines(picker).join("\n"); + expect(rendered).not.toContain("ctrl+s"); + expect(rendered).toContain("★ Favorites · 2"); + expect(rendered).not.toMatch(/Search: \S/u); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index d795f506..274bb689 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -911,6 +911,8 @@ "packages/local-runtime-v2/src/service/model-system/catalog/list-models.ts", "packages/local-runtime-v2/src/service/model-system/catalog/minimax-api.ts", "packages/local-runtime-v2/src/service/model-system/catalog/model-cache.ts", + "packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.test.ts", + "packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.ts", "packages/local-runtime-v2/src/service/model-system/catalog/model-selection.test.ts", "packages/local-runtime-v2/src/service/model-system/catalog/model-selection.ts", "packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.client.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 40ae8d55..dbd9f701 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -56,6 +56,7 @@ "packages/local-runtime-v2/src/service/model-system/catalog/agent-model-selection.test.ts", "packages/local-runtime-v2/src/service/model-system/catalog/catalog.test.ts", "packages/local-runtime-v2/src/service/model-system/catalog/list-models.test.ts", + "packages/local-runtime-v2/src/service/model-system/catalog/model-favorites.test.ts", "packages/local-runtime-v2/src/service/model-system/catalog/model-selection.test.ts", "packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts", "packages/local-runtime-v2/src/service/model-system/connectivity/discover-models.test.ts",