From b09c300e75221470c1e8ebdc8b972228bc8c2f98 Mon Sep 17 00:00:00 2001 From: piekstra Date: Thu, 8 Oct 2026 10:25:25 -0400 Subject: [PATCH] feat(ui): pick the AI model from the resolved CLI's models Model is a list of the aliases the resolved agent CLI accepts, with Other for any name, and is hidden for a custom command, which receives no model. Switching provider resets the model. --- docs/ai.md | 8 ++++- ui/src/lib/ai.ts | 27 ++++++++++++++- ui/src/pages/Settings.tsx | 72 ++++++++++++++++++++++++++++++--------- ui/src/styles.css | 11 ++++++ ui/src/test/ai.test.ts | 11 +++++- 5 files changed, 109 insertions(+), 20 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 45ab435..e84d952 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -36,7 +36,7 @@ In the UI: Settings, AI. From the CLI: lest config set ai.provider auto # the first agent CLI found on PATH lest config set ai.provider command # any program: prompt on stdin, text on stdout lest config set ai.command 'my-llm --quiet' -lest config set ai.model small-model # optional, passed to agent CLIs +lest config set ai.model sonnet # optional, passed to agent CLIs lest config set ai.agent 'my-agent {prompt}' # what Open in agent runs lest config set ai.dailyLimit 50 lest config show @@ -49,6 +49,12 @@ lest config show | a supported agent CLI | That CLI, headless, in an empty temporary directory, for one answer. CLIs that can turn tools off run with no tools, no MCP servers and no user customizations; a CLI that cannot runs in its read-only sandbox without the user's config or rules, so it can still read files to answer. | | `command` | Any program that reads the prompt on stdin and prints the answer. Labeled by the program's name. | +The model defaults to the CLI's own small model. In Settings, Model lists +the aliases the resolved CLI accepts (for `claude`: Haiku, the default, +Sonnet, Opus and Fable), and Other takes any name the CLI understands. A +custom command receives no model, so the row is hidden for it. Switching +provider resets the model to the new CLI's default. + Every call, writing the prompt included, is stopped after 90 seconds, and the provider's whole process group is killed. Prompts are cut at 48 KB. The provider never sees the environment variables the env secret backends read; diff --git a/ui/src/lib/ai.ts b/ui/src/lib/ai.ts index 4c363a7..77f5c03 100644 --- a/ui/src/lib/ai.ts +++ b/ui/src/lib/ai.ts @@ -3,7 +3,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { api } from "../api"; -import type { AiConfig, AiStatus, Explanation, SettingsResponse } from "../types"; +import type { AiConfig, AiProvider, AiStatus, Explanation, SettingsResponse } from "../types"; // Settings, fetched once per page load and replaced on every save. @@ -146,6 +146,31 @@ export function providerOptions(ai: AiConfig, status: AiStatus): ProviderOption[ ]; } +export interface ModelChoice { + /** Passed as the model; empty means the CLI's default. */ + value: string; + label: string; +} + +/** The models offered for an agent CLI, its default first, or null when + * the provider takes no model (off, or a custom command). Names are the + * aliases each CLI accepts; any other name can still be typed. */ +export function modelChoices(kind: AiProvider | null | undefined): ModelChoice[] | null { + switch (kind) { + case "claude": + return [ + { value: "", label: "Haiku (default)" }, + { value: "sonnet", label: "Sonnet" }, + { value: "opus", label: "Opus" }, + { value: "fable", label: "Fable" }, + ]; + case "codex": + return [{ value: "", label: "Codex default" }]; + default: + return null; + } +} + /** Whether the step menu offers the agent items. */ export function agentConfigured(status: AiStatus | null | undefined, handoffCommand: string | null | undefined): boolean { // Only a command Lest can run counts: a provider alone (a custom diff --git a/ui/src/pages/Settings.tsx b/ui/src/pages/Settings.tsx index c2211f6..a413fdc 100644 --- a/ui/src/pages/Settings.tsx +++ b/ui/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { IconDisplay, IconMoon, IconSun } from "../components/icons"; import { CopyButton } from "../components/ui"; -import { aiStatusLine, providerOptions, saveAi, useAiSettings } from "../lib/ai"; +import { aiStatusLine, modelChoices, providerOptions, saveAi, useAiSettings } from "../lib/ai"; import { readTheme, saveTheme, useProject, type Theme } from "../lib/project"; import type { AiConfig, SettingsResponse } from "../types"; @@ -79,6 +79,7 @@ export function SettingsPage() { } const TEXT_SAVE_DELAY_MS = 600; +const OTHER_MODEL = "__other__"; function AiSection() { const settings = useAiSettings(); @@ -98,6 +99,7 @@ function AiForm({ settings }: { settings: SettingsResponse }) { const [limitText, setLimitText] = useState(settings.ai.dailyLimit ? String(settings.ai.dailyLimit) : ""); const [saved, setSaved] = useState<"" | "saving" | "saved">(""); const [saveError, setSaveError] = useState(null); + const [otherModel, setOtherModel] = useState(false); const timer = useRef | undefined>(undefined); const pending = useRef(null); const latest = useRef(ai); @@ -140,6 +142,11 @@ function AiForm({ settings }: { settings: SettingsResponse }) { const status = settings.status; const options = providerOptions(ai, status); + // The model list follows the CLI the provider resolved to, so Automatic + // offers the models of whichever CLI it found. + const models = ai.provider === status.configured ? modelChoices(status.resolved?.kind) : null; + const knownModel = models?.some((m) => m.value === (ai.model ?? "")) ?? false; + const showOther = otherModel || !knownModel; return ( <>
@@ -157,7 +164,12 @@ function AiForm({ settings }: { settings: SettingsResponse }) { type="button" role="radio" aria-checked={ai.provider === o.id} - onClick={() => change({ provider: o.id }, false)} + onClick={() => { + if (o.id === ai.provider) return; + // A model name belongs to one CLI; another provider starts at its default. + setOtherModel(false); + change({ provider: o.id, model: undefined }, false); + }} > {o.label} @@ -182,22 +194,48 @@ function AiForm({ settings }: { settings: SettingsResponse }) { />
)} -
-
- -
Passed to the agent CLI; a custom command does not receive it.
+ {models && ( +
+
+ +
+ Which model {status.resolved?.label} answers with. Larger models explain better, take longer and cost more. +
+
+
+ + {showOther && ( + change({ model: e.target.value }, true)} + /> + )} +
- change({ model: e.target.value }, true)} - /> -
+ )}