Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+40 -82
View File
@@ -23,10 +23,12 @@ import { useI18n } from "@/components/i18n";
import { useEffect, useRef, useState } from "react";
import type { ModelCatalogEntry } from "@/src/core";
import { fuzzyScore, subscriptionLabel } from "@/src/core";
import { CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
import { formatTokens } from "@/components/usage-format";
import { SearchMenuList } from "./search-menu-list";
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
if (!cost) return "Unpriced";
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
return `${fmt(cost.input)}/${fmt(cost.output)}`;
}
@@ -40,6 +42,7 @@ export function ModelMenuList({
effectiveProvider,
onPick,
footer,
vision,
}: {
/** The full catalog — undefined while it loads. */
models: ModelCatalogEntry[] | undefined;
@@ -54,92 +57,47 @@ export function ModelMenuList({
onPick: (entry: ModelCatalogEntry) => void;
/** Quiet footer note under the list (context-specific). */
footer?: string;
/** Vision slot: only image-capable models are selectable — plus every
* custom-endpoint model, whose capability the endpoint may not report.
* Image-capable ones float to the top. */
vision?: boolean;
}) {
const { t, language } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Keep the arrow-walked result visible in the scrolling list.
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
// The filtered, ranked model list.
const results = (models ?? [])
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
.filter((r) => r.s !== null)
.sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id))
.map((r) => r.m);
const { t } = useI18n();
// Vision slot: built-ins must report image input; custom-endpoint models
// stay selectable (the endpoint may not report a modality). Image-capable
// ones float to the top of the list.
const visionItems = vision
? models?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
: models;
return (
<>
<input
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setMenuIdx(null); // new query → the walk restarts on Enter/↓
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
if (results.length === 0) return;
setMenuIdx((i) =>
e.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0),
);
} else if (e.key === "Enter") {
const chosen = results[menuIdx ?? 0];
if (chosen) {
e.preventDefault();
e.stopPropagation();
onPick(chosen);
}
}
}}
<SearchMenuList
items={visionItems}
itemKey={(m) => `${m.provider}/${m.id}`}
searchTerms={(m) => [m.provider, m.id, m.name]}
compare={(a, b) => a.id.localeCompare(b.id)}
prioritize={vision ? (m) => m.input.includes("image") : undefined}
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
onPick={onPick}
searchTestid="model-search"
listTestid="model-list"
optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
placeholder={t("Search models…")}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
loading={t("Loading models…")}
empty={t("No models match.")}
renderItem={(m) => <>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={t(m.provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : m.cost ? "USD per 1M tokens in/out" : "Price unavailable")}>
{t(m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost))}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{m.contextWindow === null ? "—" : formatTokens(m.contextWindow)}</span>
</span>
</>}
/>
{!models && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading models…")}</div>}
{models && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">{t("No models match.")}</div>
)}
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => onPick(m)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? t("Subscription connection") : t("USD per 1M tokens in/out")}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
</span>
</button>
</li>
))}
</ul>
{footer && (
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
)}