feat: per-model max output tokens and a conversation-time thinking level backed by agent settings (#28)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-22 22:22:41 +08:00
committed by GitHub
parent 99f391f379
commit cabbab1c16
36 changed files with 797 additions and 42 deletions
@@ -11,6 +11,10 @@
* configured-key-first list with a bottom "show all" row — see ModelSelect) — once
* the Session is created the model is locked, and the same spot switches to a read-only
* logo + name display;
* Draft state also renders a thinking-level picker left of the model selector (backed by the
* Agent settings: picking a level writes through to the Agent config and applies to the session
* created on first send); in session state the level is fixed (llmConfig is assembled once per
* session), shown as a read-only tag from session_meta;
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
* installed skill gets its own entry; pressing Enter on `/<skill_name>` toggles that skill's
* selection without sending). Matching is positional like `@`: a slash opens the menu from any
@@ -60,6 +64,7 @@ import { ProviderLogo } from "../../components/ui/provider-logo";
import { hasConfiguredKey, sameModelRef, visibleChatModels } from "../models/model-grouping";
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
import { matchSlash, removeSlashToken } from "./slash-token";
import { THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level";
import {
BOOK_ICON,
buildSkillsMessage,
@@ -379,6 +384,95 @@ function ModelSelect({
);
}
/** Spark glyph for the thinking-level picker (24x24 line path, consistent with the toolbar icon set). */
const SPARK_ICON = "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z";
/**
* Conversation-time thinking-level picker (draft state only, docked left of the model
* selector): shows the **selected Agent's** current `model.thinking_level` and writes a picked
* level straight through to the Agent settings — llmConfig is assembled once per session, so
* the level applies to the session created on first send and becomes the Agent's new default
* (switch-becomes-default). Per review: a title bar names the control, and the menu lists
* exactly the five levels with short names only (no descriptions, no "default" row) — an
* Agent without an explicit override shows an em dash until a level is picked.
*/
function ThinkingLevelSelect({
value,
onChange,
disabled,
}: {
/** Current level ("" = no override yet); null = the Agent config is still loading. */
value: string | null;
onChange: (level: string) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const label =
value === null ? "…" : (thinkingLevelLabel(S.chat.thinkingLevelNames, value) ?? "—");
return (
<Dropdown
open={open}
setOpen={setOpen}
menuClass="right-0 top-full mt-1 w-36 origin-top-right"
button={
<button
type="button"
title={`${S.chat.thinkingLevel}:${label}`}
aria-label={S.chat.thinkingLevel}
disabled={disabled || value === null}
onClick={() => setOpen(!open)}
className="flex h-8 max-w-36 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<GlyphIcon d={SPARK_ICON} size={14} className="shrink-0" />
{/* When the card is narrower than @md, only the icon remains (title shows the full state). */}
<span className="hidden min-w-0 truncate @md:block">{label}</span>
<svg
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
className="shrink-0"
aria-hidden
>
<path
d="M3 4.5l3 3 3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
}
>
{/* Title bar: names the control (the rows themselves are just the five short names). */}
<div className="border-b border-gray-100 px-3 pb-1.5 pt-0.5 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:text-gray-400">
{S.chat.thinkingLevel}
</div>
{THINKING_LEVELS.map((level) => (
<button
key={level}
type="button"
onClick={() => {
onChange(level);
setOpen(false);
}}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${
level === value
? "font-medium text-gray-900 dark:text-gray-100"
: "text-gray-600 dark:text-gray-400"
}`}
>
<span className="min-w-0 flex-1 truncate">
{S.chat.thinkingLevelNames[level] ?? level}
</span>
<span className="w-3 shrink-0 text-center">{level === value ? "✓" : ""}</span>
</button>
))}
</Dropdown>
);
}
/**
* Multi-select skills dropdown (bottom toolbar, after approval mode): styled like the model
* selector — button = book icon + "Skills" label + selected-count badge (no badge at 0; when the
@@ -588,6 +682,9 @@ export function ChatInput({
models,
onChangeModel,
defaultModel,
thinkingLevel,
onChangeThinkingLevel,
sessionThinkingLevel,
contextWindow,
contextNow,
contextStale = false,
@@ -629,6 +726,24 @@ export function ChatInput({
onChangeModel?: (ref: ModelRefDto) => void;
/** Project default model (marked "default" on the selector's candidate item). */
defaultModel?: ModelRefDto;
/**
* Draft state: the selected Agent's current thinking level ("" = no override / provider
* default; null while the Agent config is loading — the picker renders disabled). Supplied
* together with onChangeThinkingLevel; without the callback the picker isn't rendered.
*/
thinkingLevel?: string | null;
/**
* Draft state: writes the picked level straight through to the Agent settings (the parent
* persists it via the agent-config API; the session created on first send picks it up and it
* becomes the Agent's new default).
*/
onChangeThinkingLevel?: (level: string) => void;
/**
* Session state: the session's fixed thinking level (captured from session_meta on replay;
* llmConfig is assembled once per session, so it cannot change mid-session). Rendered as a
* read-only tag next to the locked model; null/undefined = unknown (nothing shown).
*/
sessionThinkingLevel?: string | null;
/** Model's context window (from models config; when not configured, the ring's cap falls back to 128000 via resolveContextWindow). */
contextWindow?: number;
/** Current context usage (total of the most recent main-session Request). */
@@ -1265,6 +1380,31 @@ export function ChatInput({
{...(contextWindow !== undefined ? { window: contextWindow } : {})}
/>
)}
{/* Draft state: conversation-time thinking level (backed by Agent settings), docked left of the model selector. */}
{models && onChangeModel && onChangeThinkingLevel && (
<ThinkingLevelSelect
value={thinkingLevel ?? null}
onChange={onChangeThinkingLevel}
disabled={busy}
/>
)}
{/* Session state: the session's fixed thinking level (from session_meta), read-only next
to the locked model; hidden when it isn't one of the five levels (e.g. "default"). */}
{!onChangeModel &&
(() => {
const label = thinkingLevelLabel(S.chat.thinkingLevelNames, sessionThinkingLevel);
return (
label && (
<span
title={`${S.chat.thinkingLevel}:${label}`}
className="hidden h-8 shrink-0 items-center gap-1 px-1 text-xs text-gray-400 @md:flex dark:text-gray-500"
>
<GlyphIcon d={SPARK_ICON} size={13} className="shrink-0" />
{label}
</span>
)
);
})()}
{/* Left of the send button: model selector in draft state; once the Session is created the model is locked, shown read-only (still with the provider logo). */}
{models && onChangeModel ? (
<ModelSelect