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
+2
View File
@@ -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
View File
@@ -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",
}), }),
+34 -24
View File
@@ -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,
}; };
@@ -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 };
}
@@ -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;
} }
+10
View File
@@ -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",
+21
View File
@@ -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,
+1 -1
View File
@@ -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"));