Add DeepSeek TUI to the ACP catalog (#1096)
* Consolidate provider selection handling * List DeepSeek TUI in docs and website
This commit is contained in:
@@ -339,6 +339,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
|||||||
|
|
||||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||||
|
|
||||||
|
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||||
|
|
||||||
### Adding a generic ACP provider
|
### Adding a generic ACP provider
|
||||||
|
|
||||||
Set `extends: "acp"` and provide a `command`:
|
Set `extends: "acp"` and provide a `command`:
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ Anyone who builds software:
|
|||||||
|
|
||||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||||
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||||
- Voice mode: dictate prompts or talk through problems hands-free
|
- Voice mode: dictate prompts or talk through problems hands-free
|
||||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ import {
|
|||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import {
|
import {
|
||||||
buildModelSelectorProviders,
|
buildProviderSelectorProviders,
|
||||||
type ModelSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/components/combined-model-selector.utils";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
@@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
modelSelectorProviders?: ModelSelectorProvider[];
|
modelSelectorProviders?: ProviderSelectorProvider[];
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
@@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelectModel: (modelId: string) => void;
|
onSelectModel: (modelId: string) => void;
|
||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
isAllModelsLoading: boolean;
|
isAllModelsLoading: boolean;
|
||||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
@@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
|
|||||||
function buildFallbackModelSelectorProviders(
|
function buildFallbackModelSelectorProviders(
|
||||||
provider: string,
|
provider: string,
|
||||||
modelOptions: StatusOption[] | undefined,
|
modelOptions: StatusOption[] | undefined,
|
||||||
): ModelSelectorProvider[] {
|
): ProviderSelectorProvider[] {
|
||||||
if (!modelOptions || modelOptions.length === 0) {
|
if (!modelOptions || modelOptions.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders(
|
|||||||
{
|
{
|
||||||
id: provider,
|
id: provider,
|
||||||
label: provider,
|
label: provider,
|
||||||
rows: modelOptions.map((option) => ({
|
modelSelection: {
|
||||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
kind: "models",
|
||||||
provider,
|
rows: modelOptions.map((option) => ({
|
||||||
providerLabel: provider,
|
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||||
modelId: option.id,
|
provider,
|
||||||
modelLabel: option.label,
|
providerLabel: provider,
|
||||||
})),
|
modelId: option.id,
|
||||||
|
modelLabel: option.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps {
|
|||||||
canSelectMode: boolean;
|
canSelectMode: boolean;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
canSelectThinking: boolean;
|
canSelectThinking: boolean;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
modelDisabled: boolean;
|
modelDisabled: boolean;
|
||||||
comboboxProviderOptions: ComboboxOption[];
|
comboboxProviderOptions: ComboboxOption[];
|
||||||
comboboxModeOptions: ComboboxOption[];
|
comboboxModeOptions: ComboboxOption[];
|
||||||
@@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps {
|
|||||||
canSelectMode: boolean;
|
canSelectMode: boolean;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
canSelectThinking: boolean;
|
canSelectThinking: boolean;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
modelDisabled: boolean;
|
modelDisabled: boolean;
|
||||||
comboboxModeOptions: ComboboxOption[];
|
comboboxModeOptions: ComboboxOption[];
|
||||||
comboboxThinkingOptions: ComboboxOption[];
|
comboboxThinkingOptions: ComboboxOption[];
|
||||||
@@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
[agent?.provider, models],
|
[agent?.provider, models],
|
||||||
);
|
);
|
||||||
const agentModelSelectorProviders = useMemo(
|
const agentModelSelectorProviders = useMemo(
|
||||||
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
|
() =>
|
||||||
|
buildProviderSelectorProviders({
|
||||||
|
providerDefinitions: agentProviderDefinitions,
|
||||||
|
modelsByProvider: agentProviderModels,
|
||||||
|
}),
|
||||||
[agentProviderDefinitions, agentProviderModels],
|
[agentProviderDefinitions, agentProviderModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import {
|
import {
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
type ModelSelectorProvider,
|
getAllProviderModelRows,
|
||||||
type SelectorModelRow,
|
getProviderDefaultLabel,
|
||||||
} from "./combined-model-selector.utils";
|
getProviderModelRows,
|
||||||
|
resolveSelectedModelLabel,
|
||||||
|
type ProviderSelectionModelRow,
|
||||||
|
type ProviderSelectorProvider,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
// TODO: this should be configured per provider in the provider manifest
|
// TODO: this should be configured per provider in the provider manifest
|
||||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||||
@@ -59,7 +63,7 @@ type SelectorView =
|
|||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providers: ModelSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
@@ -79,7 +83,7 @@ interface CombinedModelSelectorProps {
|
|||||||
|
|
||||||
interface SelectorContentProps {
|
interface SelectorContentProps {
|
||||||
view: SelectorView;
|
view: SelectorView;
|
||||||
providers: ModelSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -89,23 +93,16 @@ interface SelectorContentProps {
|
|||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
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 {
|
function normalizeSearchQuery(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortFavoritesFirst(
|
function sortFavoritesFirst(
|
||||||
rows: SelectorModelRow[],
|
rows: ProviderSelectionModelRow[],
|
||||||
favoriteKeys: Set<string>,
|
favoriteKeys: Set<string>,
|
||||||
): SelectorModelRow[] {
|
): ProviderSelectionModelRow[] {
|
||||||
const favorites: SelectorModelRow[] = [];
|
const favorites: ProviderSelectionModelRow[] = [];
|
||||||
const rest: SelectorModelRow[] = [];
|
const rest: ProviderSelectionModelRow[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (favoriteKeys.has(row.favoriteKey)) {
|
if (favoriteKeys.has(row.favoriteKey)) {
|
||||||
favorites.push(row);
|
favorites.push(row);
|
||||||
@@ -124,7 +121,7 @@ function ModelRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
row: SelectorModelRow;
|
row: ProviderSelectionModelRow;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
elevated?: boolean;
|
elevated?: boolean;
|
||||||
@@ -200,7 +197,7 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SelectableModelRowProps {
|
interface SelectableModelRowProps {
|
||||||
row: SelectorModelRow;
|
row: ProviderSelectionModelRow;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
elevated?: boolean;
|
elevated?: boolean;
|
||||||
@@ -239,7 +236,7 @@ function FavoritesSection({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
favoriteRows: SelectorModelRow[];
|
favoriteRows: ProviderSelectionModelRow[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
@@ -274,7 +271,7 @@ interface GroupProviderButtonProps {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerLabel: string;
|
providerLabel: string;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
hasNoModels: boolean;
|
defaultLabel: string | null;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onSelectDefault: (providerId: string) => void;
|
onSelectDefault: (providerId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -283,28 +280,28 @@ function GroupProviderButton({
|
|||||||
providerId,
|
providerId,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
rowCount,
|
rowCount,
|
||||||
hasNoModels,
|
defaultLabel,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
onSelectDefault,
|
onSelectDefault,
|
||||||
}: GroupProviderButtonProps) {
|
}: GroupProviderButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProvIcon = getProviderIcon(providerId);
|
const ProvIcon = getProviderIcon(providerId);
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (hasNoModels) {
|
if (defaultLabel) {
|
||||||
onSelectDefault(providerId);
|
onSelectDefault(providerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDrillDown(providerId, providerLabel);
|
onDrillDown(providerId, providerLabel);
|
||||||
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>
|
<Text style={styles.drillDownCount}>
|
||||||
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||||
</Text>
|
</Text>
|
||||||
{hasNoModels ? null : (
|
{defaultLabel ? null : (
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -317,22 +314,23 @@ function GroupedProviderRows({
|
|||||||
onDrillDown,
|
onDrillDown,
|
||||||
onSelectDefault,
|
onSelectDefault,
|
||||||
}: {
|
}: {
|
||||||
providers: ModelSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onSelectDefault: (providerId: string) => void;
|
onSelectDefault: (providerId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{providers.map((provider, index) => {
|
{providers.map((provider, index) => {
|
||||||
const hasNoModels = provider.rows.length === 0;
|
const rows = getProviderModelRows(provider);
|
||||||
|
const defaultLabel = getProviderDefaultLabel(provider);
|
||||||
return (
|
return (
|
||||||
<View key={provider.id}>
|
<View key={provider.id}>
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
<GroupProviderButton
|
<GroupProviderButton
|
||||||
providerId={provider.id}
|
providerId={provider.id}
|
||||||
providerLabel={provider.label}
|
providerLabel={provider.label}
|
||||||
rowCount={provider.rows.length}
|
rowCount={rows.length}
|
||||||
hasNoModels={hasNoModels}
|
defaultLabel={defaultLabel}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
onSelectDefault={onSelectDefault}
|
onSelectDefault={onSelectDefault}
|
||||||
/>
|
/>
|
||||||
@@ -381,7 +379,7 @@ function ProviderModelRows({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
normalizedQuery,
|
normalizedQuery,
|
||||||
}: {
|
}: {
|
||||||
rows: SelectorModelRow[];
|
rows: ProviderSelectionModelRow[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
@@ -396,7 +394,7 @@ function ProviderModelRows({
|
|||||||
[favoriteKeys, normalizedQuery, rows],
|
[favoriteKeys, normalizedQuery, rows],
|
||||||
);
|
);
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: SelectorModelRow }) => (
|
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||||
<SelectableModelRow
|
<SelectableModelRow
|
||||||
row={item}
|
row={item}
|
||||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||||
@@ -407,7 +405,7 @@ function ProviderModelRows({
|
|||||||
),
|
),
|
||||||
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||||
);
|
);
|
||||||
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
|
||||||
|
|
||||||
if (useVirtualizedList) {
|
if (useVirtualizedList) {
|
||||||
return (
|
return (
|
||||||
@@ -455,15 +453,12 @@ function SelectorContent({
|
|||||||
const visibleRows = useMemo(
|
const visibleRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedViewProvider
|
selectedViewProvider
|
||||||
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
|
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
|
||||||
: [],
|
: [],
|
||||||
[normalizedQuery, selectedViewProvider],
|
[normalizedQuery, selectedViewProvider],
|
||||||
);
|
);
|
||||||
const favoriteRows = useMemo(
|
const favoriteRows = useMemo(
|
||||||
() =>
|
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||||
providers.flatMap((provider) =>
|
|
||||||
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
|
||||||
),
|
|
||||||
[favoriteKeys, providers],
|
[favoriteKeys, providers],
|
||||||
);
|
);
|
||||||
const handleSelectDefaultProvider = useCallback(
|
const handleSelectDefaultProvider = useCallback(
|
||||||
@@ -485,7 +480,7 @@ function SelectorContent({
|
|||||||
return emptyState;
|
return emptyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
|
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||||
return (
|
return (
|
||||||
<DefaultProviderRow
|
<DefaultProviderRow
|
||||||
providerId={view.providerId}
|
providerId={view.providerId}
|
||||||
@@ -607,30 +602,20 @@ export function CombinedModelSelector({
|
|||||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
if (!selectedModel) {
|
return resolveSelectedModelLabel({
|
||||||
if (!hasSelectedProvider) {
|
providers,
|
||||||
return "Select model";
|
selectedProvider,
|
||||||
}
|
selectedModel,
|
||||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
isLoading,
|
||||||
if (provider?.rows.length === 0) {
|
});
|
||||||
return "Default";
|
}, [isLoading, providers, selectedModel, selectedProvider]);
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const modelCount =
|
const provider = providers.find((entry) => entry.id === view.providerId);
|
||||||
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
|
const modelCount = provider ? getProviderModelRows(provider).length : 0;
|
||||||
return Math.min(80 + modelCount * 40, 400);
|
return Math.min(80 + modelCount * 40, 400);
|
||||||
}, [providers, view]);
|
}, [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 { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import {
|
import {
|
||||||
buildSelectableModelSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
type ModelSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/components/combined-model-selector.utils";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
@@ -32,9 +32,9 @@ import {
|
|||||||
SELECTABLE_PROVIDER_STATUSES,
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
type FormInitialValues,
|
type FormInitialValues,
|
||||||
type FormState,
|
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 {
|
export interface UseAgentFormStateOptions {
|
||||||
initialServerId?: string | null;
|
initialServerId?: string | null;
|
||||||
@@ -67,7 +67,7 @@ export interface UseAgentFormStateResult {
|
|||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
availableModels: AgentModelDefinition[];
|
availableModels: AgentModelDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
isAllModelsLoading: boolean;
|
isAllModelsLoading: boolean;
|
||||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
@@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotModelSelectorProviders = useMemo(
|
const snapshotModelSelectorProviders = useMemo(
|
||||||
() => buildSelectableModelSelectorProviders(snapshotEntries),
|
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotSelectedEntry = useMemo(
|
const snapshotSelectedEntry = useMemo(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
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 { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
export interface DraftKeyContext {
|
export interface DraftKeyContext {
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
|
|||||||
return input.draftKey;
|
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: {
|
export function buildDraftStatusControls(input: {
|
||||||
formState: UseAgentFormStateResult;
|
formState: UseAgentFormStateResult;
|
||||||
features?: DraftAgentStatusBarProps["features"];
|
features?: DraftAgentStatusBarProps["features"];
|
||||||
|
|||||||
@@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({
|
|||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
rows: [
|
modelSelection: {
|
||||||
{
|
kind: "models",
|
||||||
favoriteKey: "codex:gpt-5.4",
|
rows: [
|
||||||
provider: "codex",
|
{
|
||||||
providerLabel: "Codex",
|
favoriteKey: "codex:gpt-5.4",
|
||||||
modelId: "gpt-5.4",
|
provider: "codex",
|
||||||
modelLabel: "gpt-5.4",
|
providerLabel: "Codex",
|
||||||
isDefault: true,
|
modelId: "gpt-5.4",
|
||||||
},
|
modelLabel: "gpt-5.4",
|
||||||
],
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isAllModelsLoading: false,
|
isAllModelsLoading: false,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveDraftKey } from "./use-agent-input-draft-core";
|
||||||
import {
|
import {
|
||||||
buildDraftComposerCommandConfig,
|
buildDraftCommandConfig,
|
||||||
resolveDraftKey,
|
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
} from "./use-agent-input-draft-core";
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
describe("resolveDraftKey", () => {
|
describe("resolveDraftKey", () => {
|
||||||
it("returns a string draft key unchanged", () => {
|
it("returns a string draft key unchanged", () => {
|
||||||
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
|||||||
it("returns the selected model trimmed", () => {
|
it("returns the selected model trimmed", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerModelId({
|
resolveEffectiveComposerModelId({
|
||||||
selectedModel: " gpt-5.4-mini ",
|
provider: "codex",
|
||||||
|
modelId: " gpt-5.4-mini ",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
}),
|
}),
|
||||||
).toBe("gpt-5.4-mini");
|
).toBe("gpt-5.4-mini");
|
||||||
});
|
});
|
||||||
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
|||||||
it("returns empty string when no model selected", () => {
|
it("returns empty string when no model selected", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerModelId({
|
resolveEffectiveComposerModelId({
|
||||||
selectedModel: "",
|
provider: "codex",
|
||||||
|
modelId: "",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
}),
|
}),
|
||||||
).toBe("");
|
).toBe("");
|
||||||
});
|
});
|
||||||
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
|||||||
|
|
||||||
it("prefers the selected thinking option when present", () => {
|
it("prefers the selected thinking option when present", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerThinkingOptionId({
|
resolveEffectiveComposerThinkingOptionId(
|
||||||
selectedThinkingOptionId: "medium",
|
{
|
||||||
availableModels: models,
|
provider: "codex",
|
||||||
effectiveModelId: "gpt-5.4",
|
modelId: "gpt-5.4",
|
||||||
}),
|
modeId: "",
|
||||||
|
thinkingOptionId: "medium",
|
||||||
|
availableModels: models,
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
|
"gpt-5.4",
|
||||||
|
),
|
||||||
).toBe("medium");
|
).toBe("medium");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the model default thinking option", () => {
|
it("falls back to the model default thinking option", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerThinkingOptionId({
|
resolveEffectiveComposerThinkingOptionId(
|
||||||
selectedThinkingOptionId: "",
|
{
|
||||||
availableModels: models,
|
provider: "codex",
|
||||||
effectiveModelId: "gpt-5.4",
|
modelId: "gpt-5.4",
|
||||||
}),
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
availableModels: models,
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
|
"gpt-5.4",
|
||||||
|
),
|
||||||
).toBe("high");
|
).toBe("high");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
|||||||
describe("buildDraftComposerCommandConfig", () => {
|
describe("buildDraftComposerCommandConfig", () => {
|
||||||
it("returns undefined when cwd is empty", () => {
|
it("returns undefined when cwd is empty", () => {
|
||||||
expect(
|
expect(
|
||||||
buildDraftComposerCommandConfig({
|
buildDraftCommandConfig({
|
||||||
provider: "codex",
|
selection: {
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
cwd: " ",
|
cwd: " ",
|
||||||
modeOptions: [],
|
|
||||||
selectedMode: "",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
effectiveModelId: "gpt-5.4",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
}),
|
}),
|
||||||
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
|
|||||||
|
|
||||||
it("builds the draft command config from derived composer state", () => {
|
it("builds the draft command config from derived composer state", () => {
|
||||||
expect(
|
expect(
|
||||||
buildDraftComposerCommandConfig({
|
buildDraftCommandConfig({
|
||||||
provider: "codex",
|
selection: {
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modeId: "auto",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
availableModels: [],
|
||||||
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
|
},
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
||||||
selectedMode: "auto",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
effectiveModelId: "gpt-5.4",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import {
|
|||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import {
|
import {
|
||||||
areAttachmentsEqual,
|
areAttachmentsEqual,
|
||||||
buildDraftComposerCommandConfig,
|
|
||||||
buildDraftStatusControls,
|
buildDraftStatusControls,
|
||||||
hasDraftContent,
|
hasDraftContent,
|
||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
resolveEffectiveComposerModelId,
|
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
|
||||||
type DraftKeyInput,
|
type DraftKeyInput,
|
||||||
} from "@/hooks/use-agent-input-draft-core";
|
} from "@/hooks/use-agent-input-draft-core";
|
||||||
|
import {
|
||||||
|
buildDraftCommandConfig,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
type ProviderSelectionState,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
formState.setWorkingDir(lockedWorkingDir);
|
formState.setWorkingDir(lockedWorkingDir);
|
||||||
}, [composerOptions, formState, 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(
|
const effectiveModelId = useMemo(
|
||||||
() =>
|
() => resolveEffectiveComposerModelId(providerSelection),
|
||||||
resolveEffectiveComposerModelId({
|
[providerSelection],
|
||||||
selectedModel: formState.selectedModel,
|
|
||||||
availableModels: formState.availableModels,
|
|
||||||
}),
|
|
||||||
[formState.availableModels, formState.selectedModel],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveThinkingOptionId = useMemo(
|
const effectiveThinkingOptionId = useMemo(
|
||||||
() =>
|
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
|
||||||
resolveEffectiveComposerThinkingOptionId({
|
[effectiveModelId, providerSelection],
|
||||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
|
||||||
availableModels: formState.availableModels,
|
|
||||||
effectiveModelId,
|
|
||||||
}),
|
|
||||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||||
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
const commandDraftConfig = useMemo(
|
const commandDraftConfig = useMemo(
|
||||||
() =>
|
() =>
|
||||||
composerOptions
|
composerOptions
|
||||||
? buildDraftComposerCommandConfig({
|
? buildDraftCommandConfig({
|
||||||
provider: formState.selectedProvider,
|
selection: providerSelection,
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
modeOptions: formState.modeOptions,
|
|
||||||
selectedMode: formState.selectedMode,
|
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
featureValues: draftFeatureValues,
|
featureValues: draftFeatureValues,
|
||||||
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
draftFeatureValues,
|
draftFeatureValues,
|
||||||
|
providerSelection,
|
||||||
workingDir,
|
workingDir,
|
||||||
formState.modeOptions,
|
|
||||||
formState.selectedMode,
|
|
||||||
formState.selectedProvider,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,6 +312,7 @@ export const __private__ = {
|
|||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
buildDraftComposerCommandConfig,
|
buildDraftCommandConfig,
|
||||||
|
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||||
buildDraftStatusControls,
|
buildDraftStatusControls,
|
||||||
};
|
};
|
||||||
|
|||||||
+116
-34
@@ -5,12 +5,14 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
buildModelSelectorProviders,
|
buildProviderSelectorProviders,
|
||||||
buildSelectableModelSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
matchesSearch,
|
matchesModelSearch,
|
||||||
} from "./combined-model-selector.utils";
|
resolveSelectedModelLabel,
|
||||||
|
resolveSubmissionReadiness,
|
||||||
|
} from "./provider-selection";
|
||||||
|
|
||||||
describe("combined model selector data", () => {
|
describe("combined model selector data", () => {
|
||||||
const codexModel: AgentModelDefinition = {
|
const codexModel: AgentModelDefinition = {
|
||||||
@@ -37,7 +39,7 @@ describe("combined model selector data", () => {
|
|||||||
|
|
||||||
it("builds selector providers from ready enabled snapshot entries", () => {
|
it("builds selector providers from ready enabled snapshot entries", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
@@ -48,24 +50,27 @@ describe("combined model selector data", () => {
|
|||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
rows: [
|
modelSelection: {
|
||||||
{
|
kind: "models",
|
||||||
favoriteKey: "codex:gpt-5.4",
|
rows: [
|
||||||
provider: "codex",
|
{
|
||||||
providerLabel: "Codex",
|
favoriteKey: "codex:gpt-5.4",
|
||||||
modelId: "gpt-5.4",
|
provider: "codex",
|
||||||
modelLabel: "GPT-5.4",
|
providerLabel: "Codex",
|
||||||
description: undefined,
|
modelId: "gpt-5.4",
|
||||||
isDefault: undefined,
|
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(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
provider: "deepseek-tui",
|
provider: "deepseek-tui",
|
||||||
label: "DeepSeek TUI",
|
label: "DeepSeek TUI",
|
||||||
@@ -76,14 +81,14 @@ describe("combined model selector data", () => {
|
|||||||
{
|
{
|
||||||
id: "deepseek-tui",
|
id: "deepseek-tui",
|
||||||
label: "DeepSeek TUI",
|
label: "DeepSeek TUI",
|
||||||
rows: [],
|
modelSelection: { kind: "providerDefault", label: "Default" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes disabled providers from selector data", () => {
|
it("excludes disabled providers from selector data", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
provider: "deepseek-tui",
|
provider: "deepseek-tui",
|
||||||
label: "DeepSeek TUI",
|
label: "DeepSeek TUI",
|
||||||
@@ -96,7 +101,7 @@ describe("combined model selector data", () => {
|
|||||||
|
|
||||||
it("excludes providers that are not ready", () => {
|
it("excludes providers that are not ready", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||||
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
||||||
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
||||||
@@ -116,19 +121,25 @@ describe("combined model selector data", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
|
buildProviderSelectorProviders({
|
||||||
|
providerDefinitions,
|
||||||
|
modelsByProvider: new Map([["codex", [codexModel]]]),
|
||||||
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
rows: [
|
modelSelection: {
|
||||||
expect.objectContaining({
|
kind: "models",
|
||||||
provider: "codex",
|
rows: [
|
||||||
providerLabel: "Codex",
|
expect.objectContaining({
|
||||||
modelId: "gpt-5.4",
|
provider: "codex",
|
||||||
modelLabel: "GPT-5.4",
|
providerLabel: "Codex",
|
||||||
}),
|
modelId: "gpt-5.4",
|
||||||
],
|
modelLabel: "GPT-5.4",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -143,10 +154,10 @@ describe("combined model selector data", () => {
|
|||||||
description: "OpenCode Zen - kimi",
|
description: "OpenCode Zen - kimi",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
||||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
||||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
||||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ranks model search results by fuzzy match quality", () => {
|
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", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
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 };
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
type FormPreferences,
|
type FormPreferences,
|
||||||
type ProviderPreferences,
|
type ProviderPreferences,
|
||||||
} from "./use-form-preferences";
|
} from "@/hooks/use-form-preferences";
|
||||||
|
|
||||||
export interface FormInitialValues {
|
export interface FormInitialValues {
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
export interface WorkspaceDraftAutoSubmitConfig {
|
export interface WorkspaceDraftAutoSubmitConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -25,27 +27,19 @@ export function validateDraftSubmission(input: {
|
|||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
hasClient,
|
hasClient,
|
||||||
} = input;
|
} = input;
|
||||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
const readiness = resolveSubmissionReadiness({
|
||||||
return "Initial prompt is required";
|
text,
|
||||||
}
|
allowsEmptyAutoSubmit,
|
||||||
if (composerState.providerDefinitions.length === 0) {
|
providerCount: composerState.providerDefinitions.length,
|
||||||
return "No available providers on the selected host";
|
selection: {
|
||||||
}
|
provider: composerState.selectedProvider,
|
||||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
modelId: composerState.effectiveModelId ?? "",
|
||||||
return "Select a model";
|
availableModels: composerState.availableModels,
|
||||||
}
|
isModelLoading: composerState.isModelLoading,
|
||||||
if (composerState.isModelLoading) {
|
},
|
||||||
return "Model defaults are still loading";
|
autoSubmitConfig,
|
||||||
}
|
workspaceDirectory,
|
||||||
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
|
hasClient,
|
||||||
if (!hasSelectedModel && composerState.availableModels.length > 0) {
|
});
|
||||||
return "No model is available for the selected provider";
|
return readiness.ok ? null : (readiness.reason ?? null);
|
||||||
}
|
|
||||||
if (!workspaceDirectory) {
|
|
||||||
return "Workspace directory not found";
|
|
||||||
}
|
|
||||||
if (!hasClient) {
|
|
||||||
return "Host is not connected";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,16 @@ export const AGENT_PAGES = [
|
|||||||
metaDescription:
|
metaDescription:
|
||||||
"Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
|
"Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: "deepseek-tui",
|
||||||
|
name: "DeepSeek TUI",
|
||||||
|
title: "DeepSeek TUI from your phone",
|
||||||
|
subtitle:
|
||||||
|
"Run the DeepSeek V4 terminal coding agent on your machine, then drive it from anywhere.",
|
||||||
|
metaTitle: "DeepSeek TUI Mobile App – Run DeepSeek TUI from anywhere | Paseo",
|
||||||
|
metaDescription:
|
||||||
|
"Drive DeepSeek TUI from your phone. Launch coding sessions on your machine, monitor progress, and ship from anywhere.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: "dimcode",
|
slug: "dimcode",
|
||||||
name: "DimCode",
|
name: "DimCode",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Route as DownloadRouteImport } from "./routes/download";
|
|||||||
import { Route as DocsRouteImport } from "./routes/docs";
|
import { Route as DocsRouteImport } from "./routes/docs";
|
||||||
import { Route as DiracRouteImport } from "./routes/dirac";
|
import { Route as DiracRouteImport } from "./routes/dirac";
|
||||||
import { Route as DimcodeRouteImport } from "./routes/dimcode";
|
import { Route as DimcodeRouteImport } from "./routes/dimcode";
|
||||||
|
import { Route as DeepseekTuiRouteImport } from "./routes/deepseek-tui";
|
||||||
import { Route as DeepagentsRouteImport } from "./routes/deepagents";
|
import { Route as DeepagentsRouteImport } from "./routes/deepagents";
|
||||||
import { Route as CursorRouteImport } from "./routes/cursor";
|
import { Route as CursorRouteImport } from "./routes/cursor";
|
||||||
import { Route as CrowRouteImport } from "./routes/crow";
|
import { Route as CrowRouteImport } from "./routes/crow";
|
||||||
@@ -183,6 +184,11 @@ const DimcodeRoute = DimcodeRouteImport.update({
|
|||||||
path: "/dimcode",
|
path: "/dimcode",
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
const DeepseekTuiRoute = DeepseekTuiRouteImport.update({
|
||||||
|
id: "/deepseek-tui",
|
||||||
|
path: "/deepseek-tui",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any);
|
||||||
const DeepagentsRoute = DeepagentsRouteImport.update({
|
const DeepagentsRoute = DeepagentsRouteImport.update({
|
||||||
id: "/deepagents",
|
id: "/deepagents",
|
||||||
path: "/deepagents",
|
path: "/deepagents",
|
||||||
@@ -319,6 +325,7 @@ export interface FileRoutesByFullPath {
|
|||||||
"/crow": typeof CrowRoute;
|
"/crow": typeof CrowRoute;
|
||||||
"/cursor": typeof CursorRoute;
|
"/cursor": typeof CursorRoute;
|
||||||
"/deepagents": typeof DeepagentsRoute;
|
"/deepagents": typeof DeepagentsRoute;
|
||||||
|
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||||
"/dimcode": typeof DimcodeRoute;
|
"/dimcode": typeof DimcodeRoute;
|
||||||
"/dirac": typeof DiracRoute;
|
"/dirac": typeof DiracRoute;
|
||||||
"/docs": typeof DocsRouteWithChildren;
|
"/docs": typeof DocsRouteWithChildren;
|
||||||
@@ -368,6 +375,7 @@ export interface FileRoutesByTo {
|
|||||||
"/crow": typeof CrowRoute;
|
"/crow": typeof CrowRoute;
|
||||||
"/cursor": typeof CursorRoute;
|
"/cursor": typeof CursorRoute;
|
||||||
"/deepagents": typeof DeepagentsRoute;
|
"/deepagents": typeof DeepagentsRoute;
|
||||||
|
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||||
"/dimcode": typeof DimcodeRoute;
|
"/dimcode": typeof DimcodeRoute;
|
||||||
"/dirac": typeof DiracRoute;
|
"/dirac": typeof DiracRoute;
|
||||||
"/download": typeof DownloadRoute;
|
"/download": typeof DownloadRoute;
|
||||||
@@ -418,6 +426,7 @@ export interface FileRoutesById {
|
|||||||
"/crow": typeof CrowRoute;
|
"/crow": typeof CrowRoute;
|
||||||
"/cursor": typeof CursorRoute;
|
"/cursor": typeof CursorRoute;
|
||||||
"/deepagents": typeof DeepagentsRoute;
|
"/deepagents": typeof DeepagentsRoute;
|
||||||
|
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||||
"/dimcode": typeof DimcodeRoute;
|
"/dimcode": typeof DimcodeRoute;
|
||||||
"/dirac": typeof DiracRoute;
|
"/dirac": typeof DiracRoute;
|
||||||
"/docs": typeof DocsRouteWithChildren;
|
"/docs": typeof DocsRouteWithChildren;
|
||||||
@@ -470,6 +479,7 @@ export interface FileRouteTypes {
|
|||||||
| "/crow"
|
| "/crow"
|
||||||
| "/cursor"
|
| "/cursor"
|
||||||
| "/deepagents"
|
| "/deepagents"
|
||||||
|
| "/deepseek-tui"
|
||||||
| "/dimcode"
|
| "/dimcode"
|
||||||
| "/dirac"
|
| "/dirac"
|
||||||
| "/docs"
|
| "/docs"
|
||||||
@@ -519,6 +529,7 @@ export interface FileRouteTypes {
|
|||||||
| "/crow"
|
| "/crow"
|
||||||
| "/cursor"
|
| "/cursor"
|
||||||
| "/deepagents"
|
| "/deepagents"
|
||||||
|
| "/deepseek-tui"
|
||||||
| "/dimcode"
|
| "/dimcode"
|
||||||
| "/dirac"
|
| "/dirac"
|
||||||
| "/download"
|
| "/download"
|
||||||
@@ -568,6 +579,7 @@ export interface FileRouteTypes {
|
|||||||
| "/crow"
|
| "/crow"
|
||||||
| "/cursor"
|
| "/cursor"
|
||||||
| "/deepagents"
|
| "/deepagents"
|
||||||
|
| "/deepseek-tui"
|
||||||
| "/dimcode"
|
| "/dimcode"
|
||||||
| "/dirac"
|
| "/dirac"
|
||||||
| "/docs"
|
| "/docs"
|
||||||
@@ -619,6 +631,7 @@ export interface RootRouteChildren {
|
|||||||
CrowRoute: typeof CrowRoute;
|
CrowRoute: typeof CrowRoute;
|
||||||
CursorRoute: typeof CursorRoute;
|
CursorRoute: typeof CursorRoute;
|
||||||
DeepagentsRoute: typeof DeepagentsRoute;
|
DeepagentsRoute: typeof DeepagentsRoute;
|
||||||
|
DeepseekTuiRoute: typeof DeepseekTuiRoute;
|
||||||
DimcodeRoute: typeof DimcodeRoute;
|
DimcodeRoute: typeof DimcodeRoute;
|
||||||
DiracRoute: typeof DiracRoute;
|
DiracRoute: typeof DiracRoute;
|
||||||
DocsRoute: typeof DocsRouteWithChildren;
|
DocsRoute: typeof DocsRouteWithChildren;
|
||||||
@@ -823,6 +836,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof DimcodeRouteImport;
|
preLoaderRoute: typeof DimcodeRouteImport;
|
||||||
parentRoute: typeof rootRouteImport;
|
parentRoute: typeof rootRouteImport;
|
||||||
};
|
};
|
||||||
|
"/deepseek-tui": {
|
||||||
|
id: "/deepseek-tui";
|
||||||
|
path: "/deepseek-tui";
|
||||||
|
fullPath: "/deepseek-tui";
|
||||||
|
preLoaderRoute: typeof DeepseekTuiRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
"/deepagents": {
|
"/deepagents": {
|
||||||
id: "/deepagents";
|
id: "/deepagents";
|
||||||
path: "/deepagents";
|
path: "/deepagents";
|
||||||
@@ -1031,6 +1051,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
CrowRoute: CrowRoute,
|
CrowRoute: CrowRoute,
|
||||||
CursorRoute: CursorRoute,
|
CursorRoute: CursorRoute,
|
||||||
DeepagentsRoute: DeepagentsRoute,
|
DeepagentsRoute: DeepagentsRoute,
|
||||||
|
DeepseekTuiRoute: DeepseekTuiRoute,
|
||||||
DimcodeRoute: DimcodeRoute,
|
DimcodeRoute: DimcodeRoute,
|
||||||
DiracRoute: DiracRoute,
|
DiracRoute: DiracRoute,
|
||||||
DocsRoute: DocsRouteWithChildren,
|
DocsRoute: DocsRouteWithChildren,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const Route = createFileRoute("/agents")({
|
|||||||
head: () => ({
|
head: () => ({
|
||||||
meta: pageMeta(
|
meta: pageMeta(
|
||||||
"Supported agents – Every coding agent Paseo runs | Paseo",
|
"Supported agents – Every coding agent Paseo runs | Paseo",
|
||||||
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
|
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, DeepSeek TUI, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
component: AgentsPage,
|
component: AgentsPage,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { agentRouteOptions } from "~/components/agent-route";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/deepseek-tui")(agentRouteOptions("deepseek-tui"));
|
||||||
Reference in New Issue
Block a user