Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion ui/src/lib/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
72 changes: 55 additions & 17 deletions ui/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -79,6 +79,7 @@ export function SettingsPage() {
}

const TEXT_SAVE_DELAY_MS = 600;
const OTHER_MODEL = "__other__";

function AiSection() {
const settings = useAiSettings();
Expand All @@ -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<string | null>(null);
const [otherModel, setOtherModel] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pending = useRef<AiConfig | null>(null);
const latest = useRef(ai);
Expand Down Expand Up @@ -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 (
<>
<div className="settings-row">
Expand All @@ -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}
</button>
Expand All @@ -182,22 +194,48 @@ function AiForm({ settings }: { settings: SettingsResponse }) {
/>
</div>
)}
<div className="settings-row">
<div>
<label className="label" htmlFor="ai-model">
Model
</label>
<div className="hint">Passed to the agent CLI; a custom command does not receive it.</div>
{models && (
<div className="settings-row">
<div>
<label className="label" htmlFor="ai-model">
Model
</label>
<div className="hint">
Which model {status.resolved?.label} answers with. Larger models explain better, take longer and cost more.
</div>
</div>
<div className="model-pick">
<select
id="ai-model"
className="select"
value={showOther ? OTHER_MODEL : (ai.model ?? "")}
onChange={(e) => {
const other = e.target.value === OTHER_MODEL;
setOtherModel(other);
if (!other) change({ model: e.target.value || undefined }, false);
}}
>
{models.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
<option value={OTHER_MODEL}>Other…</option>
</select>
{showOther && (
<input
className="input mono"
aria-label="Model name"
value={ai.model ?? ""}
placeholder="model name"
spellCheck={false}
autoFocus={otherModel}
onChange={(e) => change({ model: e.target.value }, true)}
/>
)}
</div>
</div>
<input
id="ai-model"
className="input"
value={ai.model ?? ""}
placeholder="the provider's small default"
spellCheck={false}
onChange={(e) => change({ model: e.target.value }, true)}
/>
</div>
)}
<div className="settings-row">
<div>
<label className="label" htmlFor="ai-agent">
Expand Down
11 changes: 11 additions & 0 deletions ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,17 @@ table.runs tr.running td {
.settings-row .seg {
flex-shrink: 0;
}
.model-pick {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
flex-shrink: 0;
}
.model-pick .select,
.settings-row .model-pick .input {
width: 100%;
}
.settings-status {
display: flex;
align-items: center;
Expand Down
11 changes: 10 additions & 1 deletion ui/src/test/ai.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { agentConfigured, aiStatusLine, explainSlot, providerOptions } from "../lib/ai";
import { agentConfigured, aiStatusLine, explainSlot, modelChoices, providerOptions } from "../lib/ai";
import type { AiConfig, AiStatus } from "../types";

const headline = 'Expected the dashboard, but the page shows "Wrong email or password".';
Expand Down Expand Up @@ -110,3 +110,12 @@ describe("AI settings", () => {
expect(agentConfigured(off, "cd '/p' && agent 'go'")).toBe(true);
});
});

describe("model choices", () => {
it("offers the CLI's default first and nothing for providers without a model", () => {
expect(modelChoices("claude")?.map((m) => m.value)).toEqual(["", "sonnet", "opus", "fable"]);
expect(modelChoices("codex")?.[0]).toEqual({ value: "", label: "Codex default" });
expect(modelChoices("command")).toBeNull();
expect(modelChoices(null)).toBeNull();
});
});
Loading