mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): expose model variant selection to plugins (#51101)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user