diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 575d2f9f9ad..f2c5792447e 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -499,6 +499,16 @@ export interface UI { /** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */ close(sessionID?: string): boolean } + readonly model: { + /** The prompt's selected model; variant is undefined for the model default. Reactive when read in a Solid computation. */ + current(): { readonly providerID: string; readonly modelID: string; readonly variant?: string } | undefined + readonly variant: { + /** Variant IDs of the selected model. Reactive when read in a Solid computation. */ + list(): readonly string[] + /** Selects a variant of the selected model, or the model default when undefined. Returns false when no model is selected or the variant is unavailable. */ + set(variant: string | undefined): boolean + } + } /** Claims a place in the slot tree; see SlotClaim. */ readonly slot: (claim: SlotClaim) => () => void } diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index f1040b81d6d..9895b1f8ab5 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -21,6 +21,7 @@ import { useAttention } from "../context/attention" import { useStorage } from "../context/storage" import { useSessionTabs } from "../context/session-tabs" import { useOptionalPanel } from "../context/panel" +import { useLocal } from "../context/local" import { abbreviateHome } from "../util/path-format" export type Dispose = () => Promise @@ -70,6 +71,7 @@ export function usePluginHost() { storage: useStorage(), sessionTabs: useSessionTabs(), panel: useOptionalPanel(), + local: useLocal(), } } @@ -249,6 +251,22 @@ export function createPluginContext(input: { return true }, }, + model: { + current() { + const selection = host.local.model.selection() + if (!selection) return + return { providerID: selection.providerID, modelID: selection.modelID, variant: selection.variant } + }, + variant: { + list: () => host.local.model.variant.list(), + set(variant) { + if (!host.local.model.selection()) return false + if (variant !== undefined && !host.local.model.variant.list().includes(variant)) return false + host.local.model.variant.set(variant) + return true + }, + }, + }, slot(value: SlotClaim) { // Keys are counter-suffixed so one plugin may claim several places; // order within the plugin is registration order. diff --git a/packages/tui/test/plugin-model.test.tsx b/packages/tui/test/plugin-model.test.tsx new file mode 100644 index 00000000000..b802f7cae8d --- /dev/null +++ b/packages/tui/test/plugin-model.test.tsx @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { createPluginContext, type Registry, type usePluginHost } from "../src/plugin/api" +import { model, renderLocal } from "./fixture/local" + +// Eagerly read host services need a shape; model access goes through the real LocalProvider. +function pluginModel(local: ReturnType["local"]) { + const host = { + app: {}, + client: {}, + keymap: {}, + shortcuts: {}, + keymapState: {}, + sessionTabs: {}, + local, + } as unknown as ReturnType + const registry: Registry = { has: () => false, set() {}, remove() {}, active: () => true } + return createPluginContext({ host, id: "test", options: undefined, owned: [], registry }).ui.model +} + +test("plugins read and select variants of the selected model", async () => { + await using setup = await renderLocal({ models: [model("first", ["low", "high"])] }) + const selected = pluginModel(setup.local) + + expect(selected.current()).toEqual({ providerID: "provider", modelID: "first", variant: undefined }) + expect(selected.variant.list()).toEqual(["low", "high"]) + + expect(selected.variant.set("high")).toBe(true) + expect(selected.current()?.variant).toBe("high") + expect(setup.local.model.variant.current()).toBe("high") + + expect(selected.variant.set(undefined)).toBe(true) + expect(selected.current()?.variant).toBeUndefined() +}) + +test("plugins cannot select unavailable variants or variants without a model", async () => { + await using setup = await renderLocal({ models: [model("first", ["low", "high"])] }) + const selected = pluginModel(setup.local) + expect(selected.variant.set("max")).toBe(false) + expect(selected.current()?.variant).toBeUndefined() + + await using empty = await renderLocal({ models: [] }) + const none = pluginModel(empty.local) + expect(none.current()).toBeUndefined() + expect(none.variant.list()).toEqual([]) + expect(none.variant.set(undefined)).toBe(false) +}) diff --git a/services/www/src/docs/content/build/plugins/cli.mdx b/services/www/src/docs/content/build/plugins/cli.mdx index d0d6363e2cf..52f7cabd557 100644 --- a/services/www/src/docs/content/build/plugins/cli.mdx +++ b/services/www/src/docs/content/build/plugins/cli.mdx @@ -389,6 +389,39 @@ if (context.ui.tabs.enabled()) { } ``` +## Model + +Read the prompt's selected model and change its variant. `current()` and `variant.list()` are reactive when read in a +Solid computation. A `variant` of `undefined` is the model default. + +```ts +const selected = context.ui.model.current() +const variants = context.ui.model.variant.list() +context.ui.model.variant.set("high") +context.ui.model.variant.set(undefined) +``` + +`variant.set` returns `false` when no model is selected or the model has no such variant. Bind it to a command to +replace the built-in variant cycle, for example to skip the model default: + +```ts +context.keymap.layer(() => ({ + commands: [ + { + id: "acme.variant.cycle", + title: "Cycle variants", + bind: "tab", + run() { + const variants = context.ui.model.variant.list() + if (variants.length === 0) return + const index = variants.indexOf(context.ui.model.current()?.variant ?? "") + context.ui.model.variant.set(variants[(index + 1) % variants.length]) + }, + }, + ], +})) +``` + ## Slots Slots insert or replace JSX at `app`, `home.footer`, `home.footer.status`, `prompt.footer`, `prompt.footer.status`,