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:
@@ -12,7 +12,12 @@ type PanelEntry = RunFooterMenuItem & {
|
||||
keywords?: string
|
||||
}
|
||||
|
||||
type CommandEntry = PanelEntry & ({ action: "model" } | { action: "slash"; name: string } | { action: "exit" })
|
||||
type CommandEntry =
|
||||
| (PanelEntry & { action: "model" })
|
||||
| (PanelEntry & { action: "variant.cycle" })
|
||||
| (PanelEntry & { action: "variant.list" })
|
||||
| (PanelEntry & { action: "slash"; name: string })
|
||||
| (PanelEntry & { action: "exit" })
|
||||
|
||||
type ModelEntry = PanelEntry & {
|
||||
providerID: string
|
||||
@@ -21,6 +26,11 @@ type ModelEntry = PanelEntry & {
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type VariantEntry = PanelEntry & {
|
||||
variant: string | undefined
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type MenuState = ReturnType<typeof createFooterMenuState>
|
||||
|
||||
const PANEL_PAD = 2
|
||||
@@ -265,8 +275,11 @@ function PanelShell(props: {
|
||||
export function RunCommandMenuBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
variants: Accessor<string[]>
|
||||
onClose: () => void
|
||||
onModel: () => void
|
||||
onVariant: () => void
|
||||
onVariantCycle: () => void
|
||||
onSlash: (name: string) => void
|
||||
onExit: () => void
|
||||
}) {
|
||||
@@ -281,6 +294,24 @@ export function RunCommandMenuBody(props: {
|
||||
display: "Switch model",
|
||||
description: "Choose model for future turns",
|
||||
},
|
||||
{
|
||||
action: "variant.cycle",
|
||||
category: "Suggested",
|
||||
display: "Variant cycle",
|
||||
description: "Cycle reasoning effort for future turns",
|
||||
keywords: "variant cycle",
|
||||
},
|
||||
...(props.variants().length > 0
|
||||
? [
|
||||
{
|
||||
action: "variant.list" as const,
|
||||
category: "Suggested",
|
||||
display: "Switch model variant",
|
||||
description: "Choose reasoning effort for future turns",
|
||||
keywords: `variant variants ${props.variants().join(" ")}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
action: "slash",
|
||||
category: "Session",
|
||||
@@ -314,6 +345,16 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "variant.cycle") {
|
||||
props.onVariantCycle()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "variant.list") {
|
||||
props.onVariant()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "exit") {
|
||||
props.onExit()
|
||||
return
|
||||
@@ -375,6 +416,103 @@ export function RunCommandMenuBody(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function RunVariantSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
variants: Accessor<string[]>
|
||||
current: Accessor<string | undefined>
|
||||
onClose: () => void
|
||||
onSelect: (variant: string | undefined) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<VariantEntry[]>(() => [
|
||||
{
|
||||
category: "",
|
||||
display: "Default",
|
||||
description: props.current() === undefined ? "current" : undefined,
|
||||
keywords: "default",
|
||||
variant: undefined,
|
||||
current: props.current() === undefined,
|
||||
},
|
||||
...props.variants().map((variant) => ({
|
||||
category: "",
|
||||
display: variant,
|
||||
description: props.current() === variant ? "current" : undefined,
|
||||
keywords: variant,
|
||||
variant,
|
||||
current: props.current() === variant,
|
||||
})),
|
||||
])
|
||||
const items = createMemo<VariantEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: VariantEntry) => {
|
||||
props.onSelect(item.variant)
|
||||
}
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
id="run-direct-footer-variant-panel"
|
||||
title="Select variant"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
>
|
||||
<RunFooterMenu
|
||||
id="run-direct-footer-variant-list"
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={false}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunModelSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
providers: Accessor<RunProvider[] | undefined>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const HINT_BREAKPOINTS = {
|
||||
send: 50,
|
||||
newline: 66,
|
||||
history: 80,
|
||||
variant: 95,
|
||||
command: 95,
|
||||
}
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
@@ -181,7 +181,7 @@ export function hintFlags(width: number) {
|
||||
send: width >= HINT_BREAKPOINTS.send,
|
||||
newline: width >= HINT_BREAKPOINTS.newline,
|
||||
history: width >= HINT_BREAKPOINTS.history,
|
||||
variant: width >= HINT_BREAKPOINTS.variant,
|
||||
command: width >= HINT_BREAKPOINTS.command,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,7 +964,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.view() === "command" || input.view() === "model") {
|
||||
if (input.view() === "command" || input.view() === "model" || input.view() === "variant") {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ import type {
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
variant?: string | undefined
|
||||
variants?: string[]
|
||||
}
|
||||
|
||||
type RunFooterOptions = {
|
||||
@@ -74,6 +76,7 @@ type RunFooterOptions = {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
@@ -84,6 +87,7 @@ type RunFooterOptions = {
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
@@ -94,6 +98,7 @@ const PERMISSION_ROWS = 12
|
||||
const QUESTION_ROWS = 14
|
||||
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||
const AUTOCOMPLETE_COMPACT_ROWS = 2
|
||||
|
||||
function createEmptySubagentState(): FooterSubagentState {
|
||||
@@ -174,6 +179,10 @@ export class RunFooter implements FooterApi {
|
||||
private setProviders: Setter<RunProvider[] | undefined>
|
||||
private currentModel: Accessor<RunInput["model"]>
|
||||
private setCurrentModel: Setter<RunInput["model"]>
|
||||
private variants: Accessor<string[]>
|
||||
private setVariants: Setter<string[]>
|
||||
private currentVariant: Accessor<string | undefined>
|
||||
private setCurrentVariant: Setter<string | undefined>
|
||||
private state: Accessor<FooterState>
|
||||
private setState: Setter<FooterState>
|
||||
private view: Accessor<FooterView>
|
||||
@@ -224,6 +233,12 @@ export class RunFooter implements FooterApi {
|
||||
const [currentModel, setCurrentModel] = createSignal<RunInput["model"]>(options.model)
|
||||
this.currentModel = currentModel
|
||||
this.setCurrentModel = setCurrentModel
|
||||
const [variants, setVariants] = createSignal<string[]>([])
|
||||
this.variants = variants
|
||||
this.setVariants = setVariants
|
||||
const [currentVariant, setCurrentVariant] = createSignal(options.variant)
|
||||
this.currentVariant = currentVariant
|
||||
this.setCurrentVariant = setCurrentVariant
|
||||
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
|
||||
this.subagent = () => subagent
|
||||
this.setSubagent = (next) => {
|
||||
@@ -256,6 +271,8 @@ export class RunFooter implements FooterApi {
|
||||
commands: this.commands,
|
||||
providers: this.providers,
|
||||
currentModel: this.currentModel,
|
||||
variants: this.variants,
|
||||
currentVariant: this.currentVariant,
|
||||
theme: options.theme,
|
||||
diffStyle: options.diffStyle,
|
||||
keybinds: options.keybinds,
|
||||
@@ -272,6 +289,7 @@ export class RunFooter implements FooterApi {
|
||||
onRequestExit: this.setRequestExitHandler,
|
||||
onExit: () => this.close(),
|
||||
onModelSelect: this.handleModelSelect,
|
||||
onVariantSelect: this.handleVariantSelect,
|
||||
onRows: this.syncRows,
|
||||
onLayout: this.syncLayout,
|
||||
onStatus: this.setStatus,
|
||||
@@ -335,6 +353,16 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "variants") {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setVariants(next.variants)
|
||||
this.setCurrentVariant(next.current)
|
||||
return
|
||||
}
|
||||
|
||||
const patch = eventPatch(next)
|
||||
if (patch) {
|
||||
this.patch(patch)
|
||||
@@ -537,6 +565,8 @@ export class RunFooter implements FooterApi {
|
||||
? 1 + tabs + COMMAND_ROWS
|
||||
: this.promptRoute.type === "model"
|
||||
? 1 + tabs + MODEL_ROWS
|
||||
: this.promptRoute.type === "variant"
|
||||
? 1 + tabs + VARIANT_ROWS
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
||||
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
||||
@@ -627,6 +657,14 @@ export class RunFooter implements FooterApi {
|
||||
status: result.status ?? "variant updated",
|
||||
}
|
||||
|
||||
if ("variants" in result) {
|
||||
this.setVariants(result.variants ?? [])
|
||||
}
|
||||
|
||||
if ("variant" in result) {
|
||||
this.setCurrentVariant(result.variant)
|
||||
}
|
||||
|
||||
if (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
}
|
||||
@@ -654,6 +692,56 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
if ("variants" in result) {
|
||||
this.setVariants(result.variants ?? [])
|
||||
}
|
||||
|
||||
if ("variant" in result) {
|
||||
this.setCurrentVariant(result.variant)
|
||||
}
|
||||
|
||||
const patch: FooterPatch = {}
|
||||
if (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
}
|
||||
|
||||
if (result.status) {
|
||||
patch.status = result.status
|
||||
}
|
||||
|
||||
if (patch.model || patch.status) {
|
||||
this.patch(patch)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private handleVariantSelect = (variant: string | undefined): void => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const model = this.currentModel()
|
||||
void Promise.resolve()
|
||||
.then(() => this.options.onVariantSelect?.(variant))
|
||||
.then((result) => {
|
||||
const current = this.currentModel()
|
||||
if (
|
||||
!result ||
|
||||
this.isClosed ||
|
||||
(model && (!current || current.providerID !== model.providerID || current.modelID !== model.modelID))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if ("variants" in result) {
|
||||
this.setVariants(result.variants ?? [])
|
||||
}
|
||||
|
||||
if ("variant" in result) {
|
||||
this.setCurrentVariant(result.variant)
|
||||
}
|
||||
|
||||
const patch: FooterPatch = {}
|
||||
if (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import * as Keybind from "@/util/keybind"
|
||||
import { RunCommandMenuBody, RunModelSelectBody } from "./footer.command"
|
||||
import { RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "./footer.command"
|
||||
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
|
||||
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
|
||||
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||
@@ -63,6 +63,8 @@ type RunFooterViewProps = {
|
||||
commands: () => RunCommand[] | undefined
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentModel: () => RunInput["model"]
|
||||
variants: () => string[]
|
||||
currentVariant: () => string | undefined
|
||||
state: () => FooterState
|
||||
view?: () => FooterView
|
||||
subagent?: () => FooterSubagentState
|
||||
@@ -82,6 +84,7 @@ type RunFooterViewProps = {
|
||||
onRequestExit?: (fn: (() => boolean) | undefined) => void
|
||||
onExit: () => void
|
||||
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||
onVariantSelect: (variant: string | undefined) => void
|
||||
onRows: (rows: number) => void
|
||||
onLayout: (input: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }) => void
|
||||
onStatus: (text: string) => void
|
||||
@@ -127,7 +130,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
|
||||
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
||||
const panel = createMemo(() => commanding() || modeling())
|
||||
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
||||
const panel = createMemo(() => commanding() || modeling() || varianting())
|
||||
const selected = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? current.sessionID : undefined
|
||||
@@ -138,7 +142,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
})
|
||||
const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
|
||||
const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const commandKeys = createMemo(() => Keybind.parse(props.keybinds.commandList))
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
@@ -195,6 +199,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openVariant = () => {
|
||||
setRoute({ type: "variant" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
setRoute({ type: "composer" })
|
||||
}
|
||||
@@ -328,7 +337,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
const current = route()
|
||||
if (current.type !== "command" && current.type !== "model") {
|
||||
if (current.type !== "command" && current.type !== "model" && current.type !== "variant") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -404,8 +413,14 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
<RunCommandMenuBody
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
variants={props.variants}
|
||||
onClose={closePanel}
|
||||
onModel={openModel}
|
||||
onVariant={openVariant}
|
||||
onVariantCycle={() => {
|
||||
props.onCycle()
|
||||
closePanel()
|
||||
}}
|
||||
onSlash={(name) => {
|
||||
composer.replaceDraft(`/${name} `)
|
||||
closePanel()
|
||||
@@ -425,6 +440,18 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={varianting()}>
|
||||
<RunVariantSelectBody
|
||||
theme={theme}
|
||||
variants={props.variants}
|
||||
current={props.currentVariant}
|
||||
onClose={closePanel}
|
||||
onSelect={(variant) => {
|
||||
props.onVariantSelect(variant)
|
||||
closePanel()
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
@@ -608,9 +635,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
{usage()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
<Show when={command().length > 0 && hints().command}>
|
||||
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
|
||||
{command()} <span style={{ fg: theme().muted }}>commands</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -113,32 +113,27 @@ function footerKeybinds(config: Config | undefined): FooterKeybinds {
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = Effect.fn("RunBoot.config")(() =>
|
||||
Effect.tryPromise({
|
||||
try: loadConfig,
|
||||
catch: () => undefined,
|
||||
}),
|
||||
)
|
||||
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
|
||||
|
||||
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
|
||||
sdk: RunInput["sdk"],
|
||||
directory: string,
|
||||
model: RunInput["model"],
|
||||
) {
|
||||
const connected = yield* Effect.tryPromise({
|
||||
try: () => sdk.config.providers({ directory }),
|
||||
catch: () => undefined,
|
||||
}).pipe(
|
||||
Effect.map((item) => item?.data?.providers),
|
||||
const connected = yield* Effect.promise(() =>
|
||||
sdk.config
|
||||
.providers({ directory })
|
||||
.then((item) => item.data?.providers)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const providers = yield* Effect.promise(() =>
|
||||
connected
|
||||
? Promise.resolve(connected)
|
||||
: sdk.provider
|
||||
.list()
|
||||
.then((item) => item.data?.all ?? [])
|
||||
.catch(() => []),
|
||||
)
|
||||
const providers = yield* (connected
|
||||
? Effect.succeed(connected)
|
||||
: Effect.tryPromise({
|
||||
try: () => sdk.provider.list(),
|
||||
catch: () => undefined,
|
||||
}).pipe(
|
||||
Effect.map((item) => item?.data?.all ?? []),
|
||||
))
|
||||
const limits = Object.fromEntries(
|
||||
providers.flatMap((provider) =>
|
||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
||||
@@ -173,10 +168,7 @@ const layer = Layer.effect(
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) {
|
||||
const session = yield* Effect.tryPromise({
|
||||
try: () => resolveSession(sdk, sessionID),
|
||||
catch: () => undefined,
|
||||
})
|
||||
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
|
||||
if (!session) {
|
||||
return emptySessionInfo()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ type SplashState = {
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
variant?: string | undefined
|
||||
variants?: string[]
|
||||
}
|
||||
|
||||
type FooterLabels = {
|
||||
@@ -66,6 +68,7 @@ export type LifecycleInput = {
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
@@ -222,6 +225,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||
...labels,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
first: input.first,
|
||||
history: input.history,
|
||||
theme,
|
||||
@@ -233,6 +237,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
onCycleVariant: input.onCycleVariant,
|
||||
onModelSelect: input.onModelSelect,
|
||||
onVariantSelect: input.onVariantSelect,
|
||||
onInterrupt: input.onInterrupt,
|
||||
onSubagentSelect: input.onSubagentSelect,
|
||||
})
|
||||
|
||||
@@ -292,6 +292,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
return {
|
||||
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
variant: state.activeVariant,
|
||||
}
|
||||
},
|
||||
onModelSelect: async (model) => {
|
||||
@@ -329,6 +330,33 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
return {
|
||||
modelLabel: formatModelLabel(model, state.activeVariant, state.providers),
|
||||
status: `model ${model.modelID}`,
|
||||
variant: state.activeVariant,
|
||||
variants: state.variants,
|
||||
}
|
||||
},
|
||||
onVariantSelect: async (variant) => {
|
||||
if (!state.model || state.variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
if (variant && !state.variants.includes(variant)) {
|
||||
return {
|
||||
status: `variant ${variant} unavailable`,
|
||||
}
|
||||
}
|
||||
|
||||
state.activeVariant = variant
|
||||
saveVariant(state.model, state.activeVariant)
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
return {
|
||||
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
variant: state.activeVariant,
|
||||
variants: state.variants,
|
||||
}
|
||||
},
|
||||
onInterrupt: () => {
|
||||
@@ -420,20 +448,22 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
state.providers = info.providers
|
||||
state.variants = variantsFor(state.providers, state.model)
|
||||
state.limits = info.limits
|
||||
if (!footer.isClosed) {
|
||||
footer.event({ type: "models", providers: info.providers })
|
||||
}
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
if (next === state.activeVariant) {
|
||||
if (next !== state.activeVariant) {
|
||||
state.activeVariant = next
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
}
|
||||
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
state.activeVariant = next
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
if (!state.model || footer.isClosed) {
|
||||
footer.event({ type: "models", providers: info.providers })
|
||||
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
||||
if (!state.model) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ export type FooterPromptRoute =
|
||||
| { type: "subagent"; sessionID: string }
|
||||
| { type: "command" }
|
||||
| { type: "model" }
|
||||
| { type: "variant" }
|
||||
|
||||
export type FooterSubagentTab = {
|
||||
sessionID: string
|
||||
@@ -209,6 +210,11 @@ export type FooterEvent =
|
||||
type: "models"
|
||||
providers: RunProvider[]
|
||||
}
|
||||
| {
|
||||
type: "variants"
|
||||
variants: string[]
|
||||
current: string | undefined
|
||||
}
|
||||
| {
|
||||
type: "queue"
|
||||
queue: number
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user