feat(tui): expose model variant selection to plugins (#51101)

This commit is contained in:
Victor Navarro
2026-09-24 14:19:27 +02:00
committed by GitHub
parent efb8e23dcc
commit c35c211460
4 changed files with 107 additions and 0 deletions
+10
View File
@@ -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
}
+18
View File
@@ -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<void>
@@ -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.
+46
View File
@@ -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<typeof usePluginHost>["local"]) {
const host = {
app: {},
client: {},
keymap: {},
shortcuts: {},
keymapState: {},
sessionTabs: {},
local,
} as unknown as ReturnType<typeof usePluginHost>
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)
})
@@ -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`,