cli: add variant selection panel to command menu

Previously model variants (reasoning effort) could only be cycled
with a keybind. Add a searchable selection panel accessible from the
command palette, matching the existing model selection flow. The
footer hint bar now advertises the command palette keybind instead
of the variant cycle shortcut.
This commit is contained in:
Simon Klee
2026-05-06 09:44:57 +02:00
parent 991ed7a7c4
commit 2c260c4b46
10 changed files with 379 additions and 54 deletions
@@ -2,7 +2,7 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RUN_COMMAND_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody } from "@/cli/cmd/run/footer.command"
import { RUN_COMMAND_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "@/cli/cmd/run/footer.command"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
@@ -17,7 +17,13 @@ function command(input: { name: string; description: string; source?: "command"
} satisfies RunCommand
}
function model(input: { id: string; name: string; status?: "active" | "deprecated"; cost?: number }) {
function model(input: {
id: string
name: string
status?: "active" | "deprecated"
cost?: number
variants?: Record<string, Record<string, never>>
}) {
return {
id: input.id,
providerID: "opencode",
@@ -64,6 +70,7 @@ function model(input: { id: string; name: string; status?: "active" | "deprecate
options: {},
headers: {},
release_date: "2026-01-01",
variants: input.variants,
} satisfies RunProvider["models"][string]
}
@@ -75,7 +82,7 @@ function provider() {
env: [],
options: {},
models: {
"gpt-5": model({ id: "gpt-5", name: "GPT-5" }),
"gpt-5": model({ id: "gpt-5", name: "GPT-5", variants: { high: {}, minimal: {} } }),
"gpt-free": model({ id: "gpt-free", name: "GPT Free", cost: 0 }),
old: model({ id: "old", name: "Old Model", status: "deprecated" }),
},
@@ -129,14 +136,18 @@ test("direct command panel renders grouped command palette", async () => {
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
])
const [variants] = createSignal(["high", "minimal"])
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunCommandMenuBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
variants={variants}
onClose={() => {}}
onModel={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onSlash={() => {}}
onExit={() => {}}
/>
@@ -154,6 +165,8 @@ test("direct command panel renders grouped command palette", async () => {
expect(frame).toContain("Search")
expect(frame).toContain("Suggested")
expect(frame).toContain("Switch model")
expect(frame).toContain("Variant cycle")
expect(frame).toContain("Switch model variant")
expect(frame).toContain("Session")
expect(frame).toContain("/new")
expect(frame).toContain("Project Commands")
@@ -199,3 +212,36 @@ test("direct model panel renders current model selector", async () => {
app.renderer.destroy()
}
})
test("direct variant panel renders current variant selector", async () => {
const [variants] = createSignal(["high", "minimal"])
const [current] = createSignal<string | undefined>("high")
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunVariantSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
variants={variants}
current={current}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Select variant")
expect(frame).toContain("Default")
expect(frame).toContain("high")
expect(frame).toContain("minimal")
expect(frame).toContain("current")
} finally {
app.renderer.destroy()
}
})
@@ -208,14 +208,7 @@ describe("run runtime boot", () => {
default: {},
connected: [],
}
spyOn(sdk.config, "providers").mockImplementation(() =>
Promise.resolve({
data: undefined,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,