Add DeepSeek TUI to the ACP catalog (#1096)

* Consolidate provider selection handling

* List DeepSeek TUI in docs and website
This commit is contained in:
Mohamed Boudra
2026-05-19 22:20:09 +08:00
committed by GitHub
parent 5dd6b030f2
commit da0e94fd18
19 changed files with 629 additions and 339 deletions
@@ -33,9 +33,9 @@ import {
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import {
buildModelSelectorProviders,
type ModelSelectorProvider,
} from "@/components/combined-model-selector.utils";
buildProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps {
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions: AgentProviderDefinition[];
modelSelectorProviders?: ModelSelectorProvider[];
modelSelectorProviders?: ProviderSelectorProvider[];
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
@@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps {
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
@@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
function buildFallbackModelSelectorProviders(
provider: string,
modelOptions: StatusOption[] | undefined,
): ModelSelectorProvider[] {
): ProviderSelectorProvider[] {
if (!modelOptions || modelOptions.length === 0) {
return [];
}
@@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders(
{
id: provider,
label: provider,
rows: modelOptions.map((option) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
provider,
providerLabel: provider,
modelId: option.id,
modelLabel: option.label,
})),
modelSelection: {
kind: "models",
rows: modelOptions.map((option) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
provider,
providerLabel: provider,
modelId: option.id,
modelLabel: option.label,
})),
},
},
];
}
@@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
modelDisabled: boolean;
comboboxProviderOptions: ComboboxOption[];
comboboxModeOptions: ComboboxOption[];
@@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
modelDisabled: boolean;
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
@@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
[agent?.provider, models],
);
const agentModelSelectorProviders = useMemo(
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
() =>
buildProviderSelectorProviders({
providerDefinitions: agentProviderDefinitions,
modelsByProvider: agentProviderModels,
}),
[agentProviderDefinitions, agentProviderModels],
);
@@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
type ModelSelectorProvider,
type SelectorModelRow,
} from "./combined-model-selector.utils";
getAllProviderModelRows,
getProviderDefaultLabel,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
@@ -59,7 +63,7 @@ type SelectorView =
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providers: ModelSelectorProvider[];
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
@@ -79,7 +83,7 @@ interface CombinedModelSelectorProps {
interface SelectorContentProps {
view: SelectorView;
providers: ModelSelectorProvider[];
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
@@ -89,23 +93,16 @@ interface SelectorContentProps {
onDrillDown: (providerId: string, providerLabel: string) => void;
}
function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string {
if (!rows || rows.length === 0) {
return "Select model";
}
return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function sortFavoritesFirst(
rows: SelectorModelRow[],
rows: ProviderSelectionModelRow[],
favoriteKeys: Set<string>,
): SelectorModelRow[] {
const favorites: SelectorModelRow[] = [];
const rest: SelectorModelRow[] = [];
): ProviderSelectionModelRow[] {
const favorites: ProviderSelectionModelRow[] = [];
const rest: ProviderSelectionModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favorites.push(row);
@@ -124,7 +121,7 @@ function ModelRow({
onPress,
onToggleFavorite,
}: {
row: SelectorModelRow;
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
elevated?: boolean;
@@ -200,7 +197,7 @@ function ModelRow({
}
interface SelectableModelRowProps {
row: SelectorModelRow;
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
elevated?: boolean;
@@ -239,7 +236,7 @@ function FavoritesSection({
onSelect,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
favoriteRows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
@@ -274,7 +271,7 @@ interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
hasNoModels: boolean;
defaultLabel: string | null;
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}
@@ -283,28 +280,28 @@ function GroupProviderButton({
providerId,
providerLabel,
rowCount,
hasNoModels,
defaultLabel,
onDrillDown,
onSelectDefault,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
if (hasNoModels) {
if (defaultLabel) {
onSelectDefault(providerId);
return;
}
onDrillDown(providerId, providerLabel);
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
</Text>
{hasNoModels ? null : (
{defaultLabel ? null : (
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
@@ -317,22 +314,23 @@ function GroupedProviderRows({
onDrillDown,
onSelectDefault,
}: {
providers: ModelSelectorProvider[];
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}) {
return (
<View>
{providers.map((provider, index) => {
const hasNoModels = provider.rows.length === 0;
const rows = getProviderModelRows(provider);
const defaultLabel = getProviderDefaultLabel(provider);
return (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton
providerId={provider.id}
providerLabel={provider.label}
rowCount={provider.rows.length}
hasNoModels={hasNoModels}
rowCount={rows.length}
defaultLabel={defaultLabel}
onDrillDown={onDrillDown}
onSelectDefault={onSelectDefault}
/>
@@ -381,7 +379,7 @@ function ProviderModelRows({
onToggleFavorite,
normalizedQuery,
}: {
rows: SelectorModelRow[];
rows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
@@ -396,7 +394,7 @@ function ProviderModelRows({
[favoriteKeys, normalizedQuery, rows],
);
const renderItem = useCallback(
({ item }: { item: SelectorModelRow }) => (
({ item }: { item: ProviderSelectionModelRow }) => (
<SelectableModelRow
row={item}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
@@ -407,7 +405,7 @@ function ProviderModelRows({
),
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
if (useVirtualizedList) {
return (
@@ -455,15 +453,12 @@ function SelectorContent({
const visibleRows = useMemo(
() =>
selectedViewProvider
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
: [],
[normalizedQuery, selectedViewProvider],
);
const favoriteRows = useMemo(
() =>
providers.flatMap((provider) =>
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
),
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
const handleSelectDefaultProvider = useCallback(
@@ -485,7 +480,7 @@ function SelectorContent({
return emptyState;
}
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
return (
<DefaultProviderRow
providerId={view.providerId}
@@ -607,30 +602,20 @@ export function CombinedModelSelector({
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => {
if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider?.rows.length === 0) {
return "Default";
}
return isLoading ? "Loading..." : "Select model";
}
const provider = providers.find((entry) => entry.id === selectedProvider);
if (!provider) {
return isLoading ? "Loading..." : "Select model";
}
const model = provider.rows.find((entry) => entry.modelId === selectedModel);
return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows);
}, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]);
return resolveSelectedModelLabel({
providers,
selectedProvider,
selectedModel,
isLoading,
});
}, [isLoading, providers, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
const modelCount =
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
const provider = providers.find((entry) => entry.id === view.providerId);
const modelCount = provider ? getProviderModelRows(provider).length : 0;
return Math.min(80 + modelCount * 40, 400);
}, [providers, view]);
@@ -1,90 +0,0 @@
import type {
AgentModelDefinition,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean };
export interface ModelSelectorProvider {
id: string;
label: string;
rows: SelectorModelRow[];
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
export function buildModelSelectorProviders(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): ModelSelectorProvider[] {
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel: definition.label,
modelId: model.id,
modelLabel: model.label,
description: model.description,
isDefault: model.isDefault,
})),
}));
}
export function buildSelectableModelSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
): ModelSelectorProvider[] {
return (entries ?? [])
.filter((entry) => entry.enabled && entry.status === "ready")
.map((entry) => ({
id: entry.provider,
label: entry.label ?? entry.provider,
rows: (entry.models ?? []).map((model) => ({
favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }),
provider: entry.provider,
providerLabel: entry.label ?? entry.provider,
modelId: model.id,
modelLabel: model.label,
description: model.description,
isDefault: model.isDefault,
})),
}));
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
return scoreModelRow(row, normalizedQuery) !== null;
}
function getModelRowSearchFields(row: SelectorModelRow): string[] {
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
}
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
}
export function filterAndRankModelRows(
rows: SelectorModelRow[],
normalizedQuery: string,
): SelectorModelRow[] {
if (!normalizedQuery) return rows;
const scored = rows
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
Boolean(entry.score),
);
scored.sort((a, b) => {
const cmp = compareMatchScores(a.score, b.score);
if (cmp !== 0) return cmp;
return a.row.modelLabel.localeCompare(b.row.modelLabel);
});
return scored.map((entry) => entry.row);
}
@@ -9,9 +9,9 @@ import type {
import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import {
buildSelectableModelSelectorProviders,
type ModelSelectorProvider,
} from "@/components/combined-model-selector.utils";
buildSelectableProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
@@ -32,9 +32,9 @@ import {
SELECTABLE_PROVIDER_STATUSES,
type FormInitialValues,
type FormState,
} from "./resolve-agent-form";
} from "@/provider-selection/resolve-agent-form";
export type { FormInitialValues } from "./resolve-agent-form";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
export interface UseAgentFormStateOptions {
initialServerId?: string | null;
@@ -67,7 +67,7 @@ export interface UseAgentFormStateResult {
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean;
@@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
() => buildSelectableModelSelectorProviders(snapshotEntries),
() => buildSelectableProviderSelectorProviders(snapshotEntries),
[snapshotEntries],
);
const snapshotSelectedEntry = useMemo(
@@ -1,8 +1,6 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
export interface DraftKeyContext {
selectedServerId: string | null;
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
return input.draftKey;
}
export function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
return input.selectedModel.trim();
}
export function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftComposerCommandConfig(input: {
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.provider || !cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
export function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
@@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({
{
id: "codex",
label: "Codex",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "gpt-5.4",
isDefault: true,
},
],
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "gpt-5.4",
isDefault: true,
},
],
},
},
],
isAllModelsLoading: false,
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveDraftKey } from "./use-agent-input-draft-core";
import {
buildDraftComposerCommandConfig,
resolveDraftKey,
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "./use-agent-input-draft-core";
} from "@/provider-selection/provider-selection";
describe("resolveDraftKey", () => {
it("returns a string draft key unchanged", () => {
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
it("returns the selected model trimmed", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: " gpt-5.4-mini ",
provider: "codex",
modelId: " gpt-5.4-mini ",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
}),
).toBe("gpt-5.4-mini");
});
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
it("returns empty string when no model selected", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: "",
provider: "codex",
modelId: "",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
}),
).toBe("");
});
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
it("prefers the selected thinking option when present", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
resolveEffectiveComposerThinkingOptionId(
{
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "medium",
availableModels: models,
modeOptions: [],
},
"gpt-5.4",
),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
resolveEffectiveComposerThinkingOptionId(
{
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: models,
modeOptions: [],
},
"gpt-5.4",
),
).toBe("high");
});
});
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
buildDraftComposerCommandConfig({
provider: "codex",
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftComposerCommandConfig({
provider: "codex",
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
cwd: "/repo",
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
+34 -24
View File
@@ -10,14 +10,17 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftComposerCommandConfig,
buildDraftStatusControls,
hasDraftContent,
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type DraftKeyInput,
} from "@/hooks/use-agent-input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
type AttachmentUpdater =
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
formState.setWorkingDir(lockedWorkingDir);
}, [composerOptions, formState, lockedWorkingDir]);
const providerSelection = useMemo<ProviderSelectionState>(
() => ({
provider: formState.selectedProvider,
modelId: formState.selectedModel,
modeId: formState.selectedMode,
thinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
modeOptions: formState.modeOptions,
}),
[
formState.availableModels,
formState.modeOptions,
formState.selectedMode,
formState.selectedModel,
formState.selectedProvider,
formState.selectedThinkingOptionId,
],
);
const effectiveModelId = useMemo(
() =>
resolveEffectiveComposerModelId({
selectedModel: formState.selectedModel,
availableModels: formState.availableModels,
}),
[formState.availableModels, formState.selectedModel],
() => resolveEffectiveComposerModelId(providerSelection),
[providerSelection],
);
const effectiveThinkingOptionId = useMemo(
() =>
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
effectiveModelId,
}),
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
[effectiveModelId, providerSelection],
);
const workingDir = lockedWorkingDir || formState.workingDir;
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const commandDraftConfig = useMemo(
() =>
composerOptions
? buildDraftComposerCommandConfig({
provider: formState.selectedProvider,
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
formState.modeOptions,
formState.selectedMode,
formState.selectedProvider,
],
);
@@ -303,6 +312,7 @@ export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftComposerCommandConfig,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftStatusControls,
};
@@ -5,12 +5,14 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
buildModelSelectorProviders,
buildSelectableModelSelectorProviders,
buildProviderSelectorProviders,
buildSelectableProviderSelectorProviders,
buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch,
} from "./combined-model-selector.utils";
matchesModelSearch,
resolveSelectedModelLabel,
resolveSubmissionReadiness,
} from "./provider-selection";
describe("combined model selector data", () => {
const codexModel: AgentModelDefinition = {
@@ -37,7 +39,7 @@ describe("combined model selector data", () => {
it("builds selector providers from ready enabled snapshot entries", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
@@ -48,24 +50,27 @@ describe("combined model selector data", () => {
{
id: "codex",
label: "Codex",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
description: undefined,
isDefault: undefined,
},
],
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
description: undefined,
isDefault: undefined,
},
],
},
},
]);
});
it("keeps ready enabled providers with no models as model-less providers", () => {
it("represents ready enabled providers without explicit models as provider-default selection", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
@@ -76,14 +81,14 @@ describe("combined model selector data", () => {
{
id: "deepseek-tui",
label: "DeepSeek TUI",
rows: [],
modelSelection: { kind: "providerDefault", label: "Default" },
},
]);
});
it("excludes disabled providers from selector data", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
@@ -96,7 +101,7 @@ describe("combined model selector data", () => {
it("excludes providers that are not ready", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
@@ -116,19 +121,25 @@ describe("combined model selector data", () => {
];
expect(
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
buildProviderSelectorProviders({
providerDefinitions,
modelsByProvider: new Map([["codex", [codexModel]]]),
}),
).toEqual([
{
id: "codex",
label: "Codex",
rows: [
expect.objectContaining({
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
}),
],
modelSelection: {
kind: "models",
rows: [
expect.objectContaining({
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
}),
],
},
},
]);
});
@@ -143,10 +154,10 @@ describe("combined model selector data", () => {
description: "OpenCode Zen - kimi",
};
expect(matchesSearch(row, "kimi zen")).toBe(true);
expect(matchesSearch(row, "zen kimi")).toBe(true);
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
expect(matchesSearch(row, "kimi gemini")).toBe(false);
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
});
it("ranks model search results by fuzzy match quality", () => {
@@ -180,4 +191,75 @@ describe("combined model selector data", () => {
it("keeps the selected trigger label model-only", () => {
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
});
it("resolves selected labels from explicit provider model-selection state", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
models: [codexModel],
}),
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
models: [],
}),
]);
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "codex",
selectedModel: "gpt-5.4",
isLoading: false,
}),
).toBe("GPT-5.4");
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "deepseek-tui",
selectedModel: "",
isLoading: false,
}),
).toBe("Default");
});
it("returns observable submission readiness reasons", () => {
expect(
resolveSubmissionReadiness({
text: "hello",
allowsEmptyAutoSubmit: false,
providerCount: 1,
selection: {
provider: "codex",
modelId: "",
availableModels: [codexModel],
isModelLoading: false,
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
}),
).toEqual({
ok: false,
reason: "No model is available for the selected provider",
});
expect(
resolveSubmissionReadiness({
text: "hello",
allowsEmptyAutoSubmit: false,
providerCount: 1,
selection: {
provider: "deepseek-tui",
modelId: "",
availableModels: [],
isModelLoading: false,
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
}),
).toEqual({ ok: true });
});
});
@@ -0,0 +1,284 @@
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
export type ProviderModelSelection =
| { kind: "models"; rows: ProviderSelectionModelRow[] }
| { kind: "providerDefault"; label: string }
| { kind: "loading" }
| { kind: "unavailable"; reason: string };
export interface ProviderSelectorProvider {
id: string;
label: string;
modelSelection: ProviderModelSelection;
}
export interface ProviderSelectionState {
provider: AgentProvider | null;
modelId: string;
modeId: string;
thinkingOptionId: string;
availableModels: AgentModelDefinition[];
modeOptions: AgentMode[];
}
export interface ProviderSelectionReadiness {
ok: boolean;
reason?: string;
}
function buildModelRows(
provider: string,
providerLabel: string,
models: AgentModelDefinition[],
): ProviderSelectionModelRow[] {
return models.map((model) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }),
provider,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
isDefault: model.isDefault,
}));
}
function buildModelSelection(
provider: string,
providerLabel: string,
models: AgentModelDefinition[] | null,
): ProviderModelSelection {
if (models === null) {
return { kind: "loading" };
}
if (models.length === 0) {
return { kind: "providerDefault", label: "Default" };
}
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
}
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
return entry.enabled && entry.status === "ready";
}
export function buildProviderSelectorProviders(input: {
providerDefinitions: AgentProviderDefinition[];
modelsByProvider: Map<string, AgentModelDefinition[]>;
}): ProviderSelectorProvider[] {
return input.providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
modelSelection: buildModelSelection(
definition.id,
definition.label,
input.modelsByProvider.has(definition.id)
? (input.modelsByProvider.get(definition.id) ?? [])
: null,
),
}));
}
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
): ProviderSelectorProvider[] {
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
const label = entry.label ?? entry.provider;
return {
id: entry.provider,
label,
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
};
});
}
export function getProviderModelRows(
provider: ProviderSelectorProvider,
): ProviderSelectionModelRow[] {
return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : [];
}
export function getAllProviderModelRows(
providers: ProviderSelectorProvider[],
): ProviderSelectionModelRow[] {
return providers.flatMap(getProviderModelRows);
}
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
}
export function resolveSelectedModelLabel(input: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
isLoading: boolean;
}): string {
const selectedProvider = input.selectedProvider.trim();
if (!selectedProvider) {
return "Select model";
}
const provider = input.providers.find((entry) => entry.id === selectedProvider);
if (!input.selectedModel) {
if (provider?.modelSelection.kind === "providerDefault") {
return provider.modelSelection.label;
}
if (provider?.modelSelection.kind === "loading") {
return "Loading...";
}
return input.isLoading ? "Loading..." : "Select model";
}
if (!provider) {
return input.isLoading ? "Loading..." : "Select model";
}
if (provider.modelSelection.kind !== "models") {
return provider.modelSelection.kind === "providerDefault"
? provider.modelSelection.label
: "Select model";
}
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
return (
model?.modelLabel ??
defaultModel?.modelLabel ??
provider.modelSelection.rows[0]?.modelLabel ??
"Select model"
);
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
export function matchesModelSearch(
row: ProviderSelectionModelRow,
normalizedQuery: string,
): boolean {
return scoreModelRow(row, normalizedQuery) !== null;
}
function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] {
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
}
export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) {
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
}
export function filterAndRankModelRows(
rows: ProviderSelectionModelRow[],
normalizedQuery: string,
): ProviderSelectionModelRow[] {
if (!normalizedQuery) return rows;
const scored = rows
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
.filter(
(
entry,
): entry is { row: ProviderSelectionModelRow; score: NonNullable<typeof entry.score> } =>
Boolean(entry.score),
);
scored.sort((a, b) => {
const cmp = compareMatchScores(a.score, b.score);
if (cmp !== 0) return cmp;
return a.row.modelLabel.localeCompare(b.row.modelLabel);
});
return scored.map((entry) => entry.row);
}
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
return selection.modelId.trim();
}
export function resolveEffectiveComposerThinkingOptionId(
selection: ProviderSelectionState,
effectiveModelId: string,
): string {
const selectedThinkingOptionId = selection.thinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
selection.availableModels.find((model) => model.id === effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftCommandConfig(input: {
selection: ProviderSelectionState;
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.selection.provider || !cwd) {
return undefined;
}
return {
provider: input.selection.provider,
cwd,
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
? { modeId: input.selection.modeId }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
export function resolveSubmissionReadiness(input: {
text: string;
allowsEmptyAutoSubmit: boolean;
providerCount: number;
selection: {
provider: AgentProvider | string | null;
modelId: string;
availableModels: readonly unknown[];
isModelLoading: boolean;
};
autoSubmitConfig: { provider: string; model: string | null } | null;
workspaceDirectory: string | null;
hasClient: boolean;
}): ProviderSelectionReadiness {
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
return { ok: false, reason: "Initial prompt is required" };
}
if (input.providerCount === 0) {
return { ok: false, reason: "No available providers on the selected host" };
}
if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) {
return { ok: false, reason: "Select a model" };
}
if (input.selection.isModelLoading) {
return { ok: false, reason: "Model defaults are still loading" };
}
const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId);
if (!hasSelectedModel && input.selection.availableModels.length > 0) {
return { ok: false, reason: "No model is available for the selected provider" };
}
if (!input.workspaceDirectory) {
return { ok: false, reason: "Workspace directory not found" };
}
if (!input.hasClient) {
return { ok: false, reason: "Host is not connected" };
}
return { ok: true };
}
@@ -8,7 +8,7 @@ import {
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
} from "@/hooks/use-form-preferences";
export interface FormInitialValues {
serverId?: string | null;
@@ -1,3 +1,5 @@
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
export interface WorkspaceDraftAutoSubmitConfig {
provider: string;
model: string | null;
@@ -25,27 +27,19 @@ export function validateDraftSubmission(input: {
workspaceDirectory,
hasClient,
} = input;
if (!allowsEmptyAutoSubmit && !text.trim()) {
return "Initial prompt is required";
}
if (composerState.providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
return "Select a model";
}
if (composerState.isModelLoading) {
return "Model defaults are still loading";
}
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
if (!hasSelectedModel && composerState.availableModels.length > 0) {
return "No model is available for the selected provider";
}
if (!workspaceDirectory) {
return "Workspace directory not found";
}
if (!hasClient) {
return "Host is not connected";
}
return null;
const readiness = resolveSubmissionReadiness({
text,
allowsEmptyAutoSubmit,
providerCount: composerState.providerDefinitions.length,
selection: {
provider: composerState.selectedProvider,
modelId: composerState.effectiveModelId ?? "",
availableModels: composerState.availableModels,
isModelLoading: composerState.isModelLoading,
},
autoSubmitConfig,
workspaceDirectory,
hasClient,
});
return readiness.ok ? null : (readiness.reason ?? null);
}