diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts
index 527a78e45..2abd45b48 100644
--- a/packages/app/src/assets/acp-provider-icons.ts
+++ b/packages/app/src/assets/acp-provider-icons.ts
@@ -27,6 +27,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
'\n',
deepagents:
'\n',
+ "deepseek-tui":
+ '\n',
dimcode:
'\n',
dirac:
diff --git a/packages/app/src/assets/acp-provider-icons/deepseek-tui.svg b/packages/app/src/assets/acp-provider-icons/deepseek-tui.svg
new file mode 100644
index 000000000..afad0032b
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/deepseek-tui.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx
index 05320cebe..cb4672b38 100644
--- a/packages/app/src/components/agent-status-bar.tsx
+++ b/packages/app/src/components/agent-status-bar.tsx
@@ -32,6 +32,10 @@ import {
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
+import {
+ buildModelSelectorProviders,
+ type ModelSelectorProvider,
+} from "@/components/combined-model-selector.utils";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -93,8 +97,7 @@ interface ControlledAgentStatusBarProps {
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions: AgentProviderDefinition[];
- allProviderModels?: Map;
- canSelectModelProvider?: (providerId: string) => boolean;
+ modelSelectorProviders?: ModelSelectorProvider[];
favoriteKeys?: Set;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
@@ -114,7 +117,7 @@ export interface DraftAgentStatusBarProps {
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
- allProviderModels: Map;
+ modelSelectorProviders: ModelSelectorProvider[];
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable;
@@ -184,10 +187,6 @@ const MODE_ICONS = {
ShieldQuestionMark,
} as const;
-function alwaysTrue() {
- return true;
-}
-
function resolveDisplayModel(
isModelLoading: boolean,
modelOptions: StatusOption[] | undefined,
@@ -235,23 +234,26 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
return (options ?? []).map((o) => ({ id: o.id, label: o.label }));
}
-function buildFallbackAllProviderModels(
+function buildFallbackModelSelectorProviders(
provider: string,
modelOptions: StatusOption[] | undefined,
-): Map {
- const map = new Map();
+): ModelSelectorProvider[] {
if (!modelOptions || modelOptions.length === 0) {
- return map;
+ return [];
}
- map.set(
- provider,
- modelOptions.map((option) => ({
- provider: provider,
- id: option.id,
- label: option.label,
- })),
- );
- return map;
+ return [
+ {
+ id: provider,
+ label: provider,
+ rows: modelOptions.map((option) => ({
+ favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
+ provider,
+ providerLabel: provider,
+ modelId: option.id,
+ modelLabel: option.label,
+ })),
+ },
+ ];
}
function makeBadgePressableStyle(
@@ -459,8 +461,7 @@ function ControlledStatusBar({
disabled = false,
isModelLoading = false,
providerDefinitions,
- allProviderModels,
- canSelectModelProvider,
+ modelSelectorProviders,
favoriteKeys = new Set(),
onToggleFavoriteModel,
features,
@@ -521,13 +522,11 @@ function ControlledStatusBar({
() => toComboboxOptions(modeOptions),
[modeOptions],
);
- const fallbackAllProviderModels = useMemo(
- () => buildFallbackAllProviderModels(provider, modelOptions),
+ const fallbackModelSelectorProviders = useMemo(
+ () => buildFallbackModelSelectorProviders(provider, modelOptions),
[modelOptions, provider],
);
- const effectiveProviderDefinitions = providerDefinitions;
- const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
- const canSelectProviderInModelMenu = canSelectModelProvider ?? alwaysTrue;
+ const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders;
const comboboxThinkingOptions = useMemo(
() => toComboboxOptions(thinkingOptions),
[thinkingOptions],
@@ -701,10 +700,8 @@ function ControlledStatusBar({
canSelectMode={canSelectMode}
canSelectModel={canSelectModel}
canSelectThinking={canSelectThinking}
- canSelectProviderInModelMenu={canSelectProviderInModelMenu}
+ modelSelectorProviders={effectiveModelSelectorProviders}
modelDisabled={modelDisabled}
- effectiveProviderDefinitions={effectiveProviderDefinitions}
- effectiveAllProviderModels={effectiveAllProviderModels}
comboboxProviderOptions={comboboxProviderOptions}
comboboxModeOptions={comboboxModeOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
@@ -751,10 +748,8 @@ function ControlledStatusBar({
canSelectMode={canSelectMode}
canSelectModel={canSelectModel}
canSelectThinking={canSelectThinking}
- canSelectProviderInModelMenu={canSelectProviderInModelMenu}
+ modelSelectorProviders={effectiveModelSelectorProviders}
modelDisabled={modelDisabled}
- effectiveProviderDefinitions={effectiveProviderDefinitions}
- effectiveAllProviderModels={effectiveAllProviderModels}
comboboxModeOptions={comboboxModeOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
ModeIconComponent={ModeIconComponent}
@@ -799,10 +794,8 @@ interface DesktopStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
- canSelectProviderInModelMenu: (providerId: string) => boolean;
+ modelSelectorProviders: ModelSelectorProvider[];
modelDisabled: boolean;
- effectiveProviderDefinitions: AgentProviderDefinition[];
- effectiveAllProviderModels: Map;
comboboxProviderOptions: ComboboxOption[];
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
@@ -868,10 +861,8 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
canSelectMode,
canSelectModel,
canSelectThinking,
- canSelectProviderInModelMenu,
+ modelSelectorProviders,
modelDisabled,
- effectiveProviderDefinitions,
- effectiveAllProviderModels,
comboboxProviderOptions,
comboboxModeOptions,
comboboxThinkingOptions,
@@ -942,11 +933,9 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
boolean;
+ modelSelectorProviders: ModelSelectorProvider[];
modelDisabled: boolean;
- effectiveProviderDefinitions: AgentProviderDefinition[];
- effectiveAllProviderModels: Map;
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
@@ -1118,10 +1105,8 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
canSelectMode,
canSelectModel,
canSelectThinking,
- canSelectProviderInModelMenu,
+ modelSelectorProviders,
modelDisabled,
- effectiveProviderDefinitions,
- effectiveAllProviderModels,
comboboxModeOptions,
comboboxThinkingOptions,
ModeIconComponent,
@@ -1214,11 +1199,9 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
<>
{canSelectModel ? (
buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models],
);
+ const agentModelSelectorProviders = useMemo(
+ () => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
+ [agentProviderDefinitions, agentProviderModels],
+ );
const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId);
@@ -1841,7 +1828,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
modeOptions={fallbackModeOptions}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
- allProviderModels={agentProviderModels}
+ modelSelectorProviders={agentModelSelectorProviders}
onSelectMode={handleSelectMode}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
@@ -1872,7 +1859,7 @@ export function DraftAgentStatusBar({
selectedModel,
onSelectModel,
isModelLoading: _isModelLoading,
- allProviderModels,
+ modelSelectorProviders,
isAllModelsLoading,
onSelectProviderAndModel,
thinkingOptions,
@@ -1937,8 +1924,7 @@ export function DraftAgentStatusBar({
return (
{
- const providerDefinitions = [
- {
- id: "claude",
- label: "Claude",
- description: "Claude provider",
- defaultModeId: "default",
- modes: [],
- },
- {
- id: "codex",
- label: "Codex",
- description: "Codex provider",
- defaultModeId: "auto",
- modes: [],
- },
- ];
+describe("combined model selector data", () => {
+ const codexModel: AgentModelDefinition = {
+ provider: "codex",
+ id: "gpt-5.4",
+ label: "GPT-5.4",
+ };
- const claudeModels: AgentModelDefinition[] = [
- {
- provider: "claude",
- id: "sonnet-4.6",
- label: "Sonnet 4.6",
- },
- ];
+ function snapshotEntry(
+ overrides: Partial & Pick,
+ ): ProviderSnapshotEntry {
+ return {
+ ...overrides,
+ provider: overrides.provider,
+ status: overrides.status ?? "ready",
+ enabled: overrides.enabled ?? true,
+ label: overrides.label ?? overrides.provider,
+ description: overrides.description ?? `${overrides.provider} provider`,
+ defaultModeId: overrides.defaultModeId ?? "default",
+ modes: overrides.modes ?? [],
+ models: overrides.models ?? [codexModel],
+ };
+ }
- const codexModels: AgentModelDefinition[] = [
- {
- provider: "codex",
- id: "gpt-5.4",
- label: "GPT-5.4",
- },
- ];
-
- it("keeps enough data to search by model and provider name", async () => {
- const rows = buildModelRows(
- providerDefinitions,
- new Map([
- ["claude", claudeModels],
- ["codex", codexModels],
+ it("builds selector providers from ready enabled snapshot entries", () => {
+ expect(
+ buildSelectableModelSelectorProviders([
+ snapshotEntry({
+ provider: "codex",
+ label: "Codex",
+ models: [codexModel],
+ }),
]),
- );
-
- expect(rows).toEqual([
- expect.objectContaining({
- providerLabel: "Claude",
- modelLabel: "Sonnet 4.6",
- modelId: "sonnet-4.6",
- }),
- expect.objectContaining({
- providerLabel: "Codex",
- modelLabel: "GPT-5.4",
- modelId: "gpt-5.4",
- }),
+ ).toEqual([
+ {
+ id: "codex",
+ label: "Codex",
+ rows: [
+ {
+ favoriteKey: "codex:gpt-5.4",
+ provider: "codex",
+ providerLabel: "Codex",
+ modelId: "gpt-5.4",
+ modelLabel: "GPT-5.4",
+ description: undefined,
+ isDefault: undefined,
+ },
+ ],
+ },
]);
+ });
- expect(matchesSearch(rows[0], "claude")).toBe(true);
- expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
+ it("keeps ready enabled providers with no models as model-less providers", () => {
+ expect(
+ buildSelectableModelSelectorProviders([
+ snapshotEntry({
+ provider: "deepseek-tui",
+ label: "DeepSeek TUI",
+ models: [],
+ }),
+ ]),
+ ).toEqual([
+ {
+ id: "deepseek-tui",
+ label: "DeepSeek TUI",
+ rows: [],
+ },
+ ]);
+ });
+
+ it("excludes disabled providers from selector data", () => {
+ expect(
+ buildSelectableModelSelectorProviders([
+ snapshotEntry({
+ provider: "deepseek-tui",
+ label: "DeepSeek TUI",
+ enabled: false,
+ models: [],
+ }),
+ ]),
+ ).toEqual([]);
+ });
+
+ it("excludes providers that are not ready", () => {
+ expect(
+ buildSelectableModelSelectorProviders([
+ snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
+ snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
+ snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
+ ]),
+ ).toEqual([]);
+ });
+
+ it("builds selector providers from an already-curated provider list", () => {
+ const providerDefinitions: AgentProviderDefinition[] = [
+ {
+ id: "codex",
+ label: "Codex",
+ description: "Codex provider",
+ defaultModeId: "auto",
+ modes: [],
+ },
+ ];
+
+ expect(
+ buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
+ ).toEqual([
+ {
+ id: "codex",
+ label: "Codex",
+ rows: [
+ expect.objectContaining({
+ provider: "codex",
+ providerLabel: "Codex",
+ modelId: "gpt-5.4",
+ modelLabel: "GPT-5.4",
+ }),
+ ],
+ },
+ ]);
});
it("matches across label, provider, and description with multi-token fuzzy search", () => {
@@ -113,7 +178,6 @@ describe("combined model selector helpers", () => {
});
it("keeps the selected trigger label model-only", () => {
- expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
});
});
diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx
index 081a6b760..23ff82cd1 100644
--- a/packages/app/src/components/combined-model-selector.tsx
+++ b/packages/app/src/components/combined-model-selector.tsx
@@ -12,8 +12,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
-import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
-import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
+import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
const IS_WEB = platformIsWeb;
@@ -46,10 +45,9 @@ function drillDownRowStyle({
}
import { getProviderIcon } from "@/components/provider-icons";
import {
- buildModelRows,
buildSelectedTriggerLabel,
filterAndRankModelRows,
- resolveProviderLabel,
+ type ModelSelectorProvider,
type SelectorModelRow,
} from "./combined-model-selector.utils";
@@ -61,13 +59,11 @@ type SelectorView =
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
- providerDefinitions: AgentProviderDefinition[];
- allProviderModels: Map;
+ providers: ModelSelectorProvider[];
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
- canSelectProvider?: (provider: string) => boolean;
favoriteKeys?: Set;
onToggleFavorite?: (provider: string, modelId: string) => void;
renderTrigger?: (input: {
@@ -83,23 +79,21 @@ interface CombinedModelSelectorProps {
interface SelectorContentProps {
view: SelectorView;
- providerDefinitions: AgentProviderDefinition[];
- allProviderModels: Map;
+ providers: ModelSelectorProvider[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
favoriteKeys: Set;
onSelect: (provider: string, modelId: string) => void;
- canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
}
-function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
- if (!models || models.length === 0) {
+function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string {
+ if (!rows || rows.length === 0) {
return "Select model";
}
- return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
+ return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
@@ -122,36 +116,10 @@ function sortFavoritesFirst(
return [...favorites, ...rest];
}
-function groupRowsByProvider(
- rows: SelectorModelRow[],
-): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
- const grouped = new Map<
- string,
- { providerId: string; providerLabel: string; rows: SelectorModelRow[] }
- >();
-
- for (const row of rows) {
- const existing = grouped.get(row.provider);
- if (existing) {
- existing.rows.push(row);
- continue;
- }
-
- grouped.set(row.provider, {
- providerId: row.provider,
- providerLabel: row.providerLabel,
- rows: [row],
- });
- }
-
- return Array.from(grouped.values());
-}
-
function ModelRow({
row,
isSelected,
isFavorite,
- disabled = false,
elevated = false,
onPress,
onToggleFavorite,
@@ -159,7 +127,6 @@ function ModelRow({
row: SelectorModelRow;
isSelected: boolean;
isFavorite: boolean;
- disabled?: boolean;
elevated?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
@@ -181,7 +148,7 @@ function ModelRow({
);
const trailingSlot = useMemo(
() =>
- onToggleFavorite && !disabled ? (
+ onToggleFavorite ? (
void;
onToggleFavorite?: (provider: string, modelId: string) => void;
@@ -248,7 +212,6 @@ function SelectableModelRow({
row,
isSelected,
isFavorite,
- disabled,
elevated,
onSelect,
onToggleFavorite,
@@ -261,7 +224,6 @@ function SelectableModelRow({
row={row}
isSelected={isSelected}
isFavorite={isFavorite}
- disabled={disabled}
elevated={elevated}
onPress={handlePress}
onToggleFavorite={onToggleFavorite}
@@ -275,7 +237,6 @@ function FavoritesSection({
selectedModel,
favoriteKeys,
onSelect,
- canSelectProvider,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
@@ -283,11 +244,8 @@ function FavoritesSection({
selectedModel: string;
favoriteKeys: Set;
onSelect: (provider: string, modelId: string) => void;
- canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
- const { theme: _theme } = useUnistyles();
-
if (favoriteRows.length === 0) {
return null;
}
@@ -303,7 +261,6 @@ function FavoritesSection({
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
- disabled={!canSelectProvider(row.provider)}
elevated
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
@@ -317,52 +274,67 @@ interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
+ hasNoModels: boolean;
onDrillDown: (providerId: string, providerLabel: string) => void;
+ onSelectDefault: (providerId: string) => void;
}
function GroupProviderButton({
providerId,
providerLabel,
rowCount,
+ hasNoModels,
onDrillDown,
+ onSelectDefault,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
+ if (hasNoModels) {
+ onSelectDefault(providerId);
+ return;
+ }
onDrillDown(providerId, providerLabel);
- }, [onDrillDown, providerId, providerLabel]);
+ }, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
return (
{providerLabel}
- {rowCount} {rowCount === 1 ? "model" : "models"}
+ {hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
-
+ {hasNoModels ? null : (
+
+ )}
);
}
function GroupedProviderRows({
- groupedRows,
+ providers,
onDrillDown,
+ onSelectDefault,
}: {
- groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
+ providers: ModelSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
+ onSelectDefault: (providerId: string) => void;
}) {
return (
- {groupedRows.map((group, index) => {
+ {providers.map((provider, index) => {
+ const hasNoModels = provider.rows.length === 0;
return (
-
+
{index > 0 ? : null}
);
@@ -371,13 +343,41 @@ function GroupedProviderRows({
);
}
+function DefaultProviderRow({
+ providerId,
+ isSelected,
+ onSelect,
+}: {
+ providerId: string;
+ isSelected: boolean;
+ onSelect: (provider: string, modelId: string) => void;
+}) {
+ const { theme } = useUnistyles();
+ const ProviderIcon = getProviderIcon(providerId);
+ const handlePress = useCallback(() => {
+ onSelect(providerId, "");
+ }, [onSelect, providerId]);
+ const leadingSlot = useMemo(
+ () => ,
+ [ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
+ );
+
+ return (
+
+ );
+}
+
function ProviderModelRows({
rows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
- canSelectProvider,
onToggleFavorite,
normalizedQuery,
}: {
@@ -386,7 +386,6 @@ function ProviderModelRows({
selectedModel: string;
favoriteKeys: Set;
onSelect: (provider: string, modelId: string) => void;
- canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
normalizedQuery: string;
}) {
@@ -402,12 +401,11 @@ function ProviderModelRows({
row={item}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
isFavorite={favoriteKeys.has(item.favoriteKey)}
- disabled={!canSelectProvider(item.provider)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
),
- [canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
+ [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
@@ -436,44 +434,45 @@ function ProviderModelRows({
function SelectorContent({
view,
- providerDefinitions,
- allProviderModels,
+ providers,
selectedProvider,
selectedModel,
searchQuery,
favoriteKeys,
onSelect,
- canSelectProvider,
onToggleFavorite,
onDrillDown,
}: SelectorContentProps) {
const { theme } = useUnistyles();
- const allRows = useMemo(
- () => buildModelRows(providerDefinitions, allProviderModels),
- [allProviderModels, providerDefinitions],
- );
-
- const scopedRows = useMemo(() => {
- if (view.kind === "provider") {
- return allRows.filter((row) => row.provider === view.providerId);
- }
- return allRows;
- }, [allRows, view]);
-
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
-
+ const selectedViewProvider = useMemo(
+ () =>
+ view.kind === "provider"
+ ? providers.find((provider) => provider.id === view.providerId)
+ : null,
+ [providers, view],
+ );
const visibleRows = useMemo(
- () => filterAndRankModelRows(scopedRows, normalizedQuery),
- [normalizedQuery, scopedRows],
+ () =>
+ selectedViewProvider
+ ? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
+ : [],
+ [normalizedQuery, selectedViewProvider],
);
-
const favoriteRows = useMemo(
- () => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
- [favoriteKeys, visibleRows],
+ () =>
+ providers.flatMap((provider) =>
+ provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
+ ),
+ [favoriteKeys, providers],
);
-
- const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
- const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
+ const handleSelectDefaultProvider = useCallback(
+ (providerId: string) => {
+ onSelect(providerId, "");
+ },
+ [onSelect],
+ );
+ const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
@@ -482,6 +481,20 @@ function SelectorContent({
);
if (view.kind === "provider") {
+ if (!selectedViewProvider) {
+ return emptyState;
+ }
+
+ if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
+ return (
+
+ );
+ }
+
if (visibleRows.length === 0) {
return emptyState;
}
@@ -493,7 +506,6 @@ function SelectorContent({
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
- canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
normalizedQuery={normalizedQuery}
/>
@@ -508,12 +520,15 @@ function SelectorContent({
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
- canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
- {allGroupedRows.length > 0 ? (
-
+ {providers.length > 0 ? (
+
) : null}
{!hasResults ? emptyState : null}
@@ -522,13 +537,11 @@ function SelectorContent({
}
export function CombinedModelSelector({
- providerDefinitions,
- allProviderModels,
+ providers,
selectedProvider,
selectedModel,
onSelect,
isLoading,
- canSelectProvider = () => true,
favoriteKeys = new Set(),
onToggleFavorite,
renderTrigger,
@@ -544,26 +557,26 @@ export function CombinedModelSelector({
const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
- // Single-provider mode: only one provider with models → skip Level 1 entirely
+ // Single-provider mode: only one provider → skip Level 1 entirely
const singleProviderView = useMemo(() => {
- const providers = Array.from(allProviderModels.keys());
if (providers.length !== 1) return null;
- const providerId = providers[0];
- const label = resolveProviderLabel(providerDefinitions, providerId);
- return { kind: "provider", providerId, providerLabel: label };
- }, [allProviderModels, providerDefinitions]);
+ const provider = providers[0];
+ if (!provider) return null;
+ return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
+ }, [providers]);
const computeInitialView = useCallback((): SelectorView => {
if (singleProviderView) return singleProviderView;
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
- const label = resolveProviderLabel(providerDefinitions, selectedProvider);
- return { kind: "provider", providerId: selectedProvider, providerLabel: label };
+ const provider = providers.find((entry) => entry.id === selectedProvider);
+ if (provider)
+ return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}
return { kind: "all" };
- }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
+ }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]);
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -598,24 +611,28 @@ export function CombinedModelSelector({
if (!hasSelectedProvider) {
return "Select model";
}
+ const provider = providers.find((entry) => entry.id === selectedProvider);
+ if (provider?.rows.length === 0) {
+ return "Default";
+ }
return isLoading ? "Loading..." : "Select model";
}
- const models = allProviderModels.get(selectedProvider);
- if (!models) {
+ const provider = providers.find((entry) => entry.id === selectedProvider);
+ if (!provider) {
return isLoading ? "Loading..." : "Select model";
}
- const model = models.find((entry) => entry.id === selectedModel);
- return model?.label ?? resolveDefaultModelLabel(models);
- }, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
+ const model = provider.rows.find((entry) => entry.modelId === selectedModel);
+ return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows);
+ }, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
- const models = allProviderModels.get(view.providerId);
- const modelCount = models?.length ?? 0;
+ const modelCount =
+ providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
return Math.min(80 + modelCount * 40, 400);
- }, [allProviderModels, view]);
+ }, [providers, view]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
@@ -750,14 +767,12 @@ export function CombinedModelSelector({
{isContentReady ? (
diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts
index 3cea39d62..b39324019 100644
--- a/packages/app/src/components/combined-model-selector.utils.ts
+++ b/packages/app/src/components/combined-model-selector.utils.ts
@@ -1,47 +1,60 @@
-import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
+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;
+export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean };
-export function resolveProviderLabel(
- providerDefinitions: AgentProviderDefinition[],
- providerId: string,
-): string {
- return (
- providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
- );
+export interface ModelSelectorProvider {
+ id: string;
+ label: string;
+ rows: SelectorModelRow[];
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
-export function buildModelRows(
+export function buildModelSelectorProviders(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map,
-): SelectorModelRow[] {
- const providerLabelMap = new Map(
- providerDefinitions.map((definition) => [definition.id, definition.label]),
- );
- const rows: SelectorModelRow[] = [];
+): 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,
+ })),
+ }));
+}
- for (const definition of providerDefinitions) {
- const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
- for (const model of allProviderModels.get(definition.id) ?? []) {
- rows.push({
- favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
- provider: definition.id,
- providerLabel,
+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,
- });
- }
- }
-
- return rows;
+ isDefault: model.isDefault,
+ })),
+ }));
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
diff --git a/packages/app/src/components/composer.status-controls.test.ts b/packages/app/src/components/composer.status-controls.test.ts
index 7491a87cb..d15de6c7b 100644
--- a/packages/app/src/components/composer.status-controls.test.ts
+++ b/packages/app/src/components/composer.status-controls.test.ts
@@ -19,7 +19,7 @@ describe("resolveStatusControlMode", () => {
selectedModel: "",
onSelectModel: () => undefined,
isModelLoading: false,
- allProviderModels: new Map(),
+ modelSelectorProviders: [],
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts
new file mode 100644
index 000000000..681f44ecf
--- /dev/null
+++ b/packages/app/src/components/provider-icons.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("lucide-react-native", () => ({
+ Bot: function Bot() {
+ return null;
+ },
+ PackagePlus: function PackagePlus() {
+ return null;
+ },
+}));
+
+import { Bot, PackagePlus } from "lucide-react-native";
+import { getProviderIcon } from "./provider-icons";
+
+describe("getProviderIcon", () => {
+ it("keeps built-in provider icons", () => {
+ expect(getProviderIcon("kiro")).toBe(PackagePlus);
+ });
+
+ it("uses vendored ACP catalog icons for catalog provider ids", () => {
+ const icon = getProviderIcon("amp-acp");
+
+ expect(icon).not.toBe(Bot);
+ expect(getProviderIcon("amp-acp")).toBe(icon);
+ });
+
+ it("falls back to the robot icon for unknown custom providers", () => {
+ expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
+ });
+});
diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts
index 1a85cf97f..941c861ad 100644
--- a/packages/app/src/components/provider-icons.ts
+++ b/packages/app/src/components/provider-icons.ts
@@ -1,19 +1,63 @@
import { Bot, PackagePlus } from "lucide-react-native";
+import { createElement, type ComponentType } from "react";
+import { SvgXml } from "react-native-svg";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
+import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
-const PROVIDER_ICONS: Record = {
- claude: ClaudeIcon as unknown as typeof Bot,
- codex: CodexIcon as unknown as typeof Bot,
- copilot: CopilotIcon as unknown as typeof Bot,
+export interface ProviderIconProps {
+ size: number;
+ color: string;
+}
+
+export type ProviderIconComponent = ComponentType;
+
+const PROVIDER_ICONS: Record = {
+ claude: ClaudeIcon as unknown as ProviderIconComponent,
+ codex: CodexIcon as unknown as ProviderIconComponent,
+ copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
- opencode: OpenCodeIcon as unknown as typeof Bot,
- pi: PiIcon as unknown as typeof Bot,
+ opencode: OpenCodeIcon as unknown as ProviderIconComponent,
+ pi: PiIcon as unknown as ProviderIconComponent,
};
-export function getProviderIcon(provider: string): typeof Bot {
- return PROVIDER_ICONS[provider] ?? Bot;
+const CATALOG_ICON_SVGS = new Map(
+ ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [[entry.id, entry.iconSvg]] : [])),
+);
+
+const catalogIconComponents = new Map();
+
+function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent {
+ const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) =>
+ createElement(SvgXml, {
+ xml: iconSvg,
+ width: size,
+ height: size,
+ color,
+ });
+ CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`;
+ return CatalogProviderIcon;
+}
+
+function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
+ const cached = catalogIconComponents.get(provider);
+ if (cached) {
+ return cached;
+ }
+
+ const iconSvg = CATALOG_ICON_SVGS.get(provider);
+ if (!iconSvg) {
+ return undefined;
+ }
+
+ const icon = createCatalogIcon(provider, iconSvg);
+ catalogIconComponents.set(provider, icon);
+ return icon;
+}
+
+export function getProviderIcon(provider: string): ProviderIconComponent {
+ return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot;
}
diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts
index 85895109c..a772bfe50 100644
--- a/packages/app/src/data/acp-provider-catalog.ts
+++ b/packages/app/src/data/acp-provider-catalog.ts
@@ -135,6 +135,15 @@ const CATALOG_DATA = [
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.7"],
},
+ {
+ id: "deepseek-tui",
+ title: "DeepSeek TUI",
+ description: "Terminal coding agent for DeepSeek V4",
+ version: "0.8.39",
+ iconId: "deepseek-tui",
+ installLink: "https://github.com/Hmbown/DeepSeek-TUI",
+ command: ["deepseek", "serve", "--acp"],
+ },
{
id: "dimcode",
title: "DimCode",
diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts
index 13ff91a1a..4ecbf0368 100644
--- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts
+++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts
@@ -37,6 +37,7 @@ describe("ACP provider catalog", () => {
it("uses PATH commands for entries that were binary distributions upstream", () => {
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
+ expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]);
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts
index 989dfbeff..fb579a53c 100644
--- a/packages/app/src/hooks/use-agent-form-state.ts
+++ b/packages/app/src/hooks/use-agent-form-state.ts
@@ -8,6 +8,10 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
+import {
+ buildSelectableModelSelectorProviders,
+ type ModelSelectorProvider,
+} from "@/components/combined-model-selector.utils";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
@@ -63,6 +67,7 @@ export interface UseAgentFormStateResult {
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map;
+ modelSelectorProviders: ModelSelectorProvider[];
isAllModelsLoading: boolean;
availableThinkingOptions: NonNullable;
isModelLoading: boolean;
@@ -237,6 +242,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
() => buildAllProviderModels(snapshotEntries),
[snapshotEntries],
);
+ const snapshotModelSelectorProviders = useMemo(
+ () => buildSelectableModelSelectorProviders(snapshotEntries),
+ [snapshotEntries],
+ );
const snapshotSelectedEntry = useMemo(
() =>
formState.provider
@@ -255,6 +264,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const providerDefinitionMap = snapshotProviderDefinitionMap;
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
const allProviderModels = snapshotAllProviderModels;
+ const modelSelectorProviders = snapshotModelSelectorProviders;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
@@ -516,6 +526,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
modeOptions,
availableModels: availableModels ?? [],
allProviderModels,
+ modelSelectorProviders,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
@@ -548,6 +559,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
modeOptions,
availableModels,
allProviderModels,
+ modelSelectorProviders,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
diff --git a/packages/app/src/hooks/use-agent-input-draft-core.ts b/packages/app/src/hooks/use-agent-input-draft-core.ts
index bde8d45ef..91c16bbab 100644
--- a/packages/app/src/hooks/use-agent-input-draft-core.ts
+++ b/packages/app/src/hooks/use-agent-input-draft-core.ts
@@ -88,7 +88,7 @@ export function buildDraftStatusControls(input: {
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
- allProviderModels: formState.allProviderModels,
+ modelSelectorProviders: formState.modelSelectorProviders,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx
index abcfe5815..07cc2aed9 100644
--- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx
+++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx
@@ -81,6 +81,22 @@ vi.mock("./use-agent-form-state", () => ({
],
],
]),
+ modelSelectorProviders: [
+ {
+ id: "codex",
+ label: "Codex",
+ rows: [
+ {
+ favoriteKey: "codex:gpt-5.4",
+ provider: "codex",
+ providerLabel: "Codex",
+ modelId: "gpt-5.4",
+ modelLabel: "gpt-5.4",
+ isDefault: true,
+ },
+ ],
+ },
+ ],
isAllModelsLoading: false,
availableThinkingOptions: [
{ id: "medium", label: "Medium" },
diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts
new file mode 100644
index 000000000..c9d0d55e2
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts
@@ -0,0 +1,51 @@
+export interface WorkspaceDraftAutoSubmitConfig {
+ provider: string;
+ model: string | null;
+}
+
+export function validateDraftSubmission(input: {
+ text: string;
+ allowsEmptyAutoSubmit: boolean;
+ composerState: {
+ providerDefinitions: unknown[];
+ selectedProvider: string | null;
+ isModelLoading: boolean;
+ effectiveModelId: string | null;
+ availableModels: unknown[];
+ };
+ autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
+ workspaceDirectory: string | null;
+ hasClient: boolean;
+}): string | null {
+ const {
+ text,
+ allowsEmptyAutoSubmit,
+ composerState,
+ autoSubmitConfig,
+ workspaceDirectory,
+ hasClient,
+ } = input;
+ if (!allowsEmptyAutoSubmit && !text.trim()) {
+ return "Initial prompt is required";
+ }
+ if (composerState.providerDefinitions.length === 0) {
+ return "No available providers on the selected host";
+ }
+ if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
+ return "Select a model";
+ }
+ if (composerState.isModelLoading) {
+ return "Model defaults are still loading";
+ }
+ const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
+ if (!hasSelectedModel && composerState.availableModels.length > 0) {
+ return "No model is available for the selected provider";
+ }
+ if (!workspaceDirectory) {
+ return "Workspace directory not found";
+ }
+ if (!hasClient) {
+ return "Host is not connected";
+ }
+ return null;
+}
diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts b/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts
new file mode 100644
index 000000000..0d7d5dff8
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, test } from "vitest";
+
+import { validateDraftSubmission } from "./workspace-draft-agent-tab-core";
+
+const baseComposerState = {
+ providerDefinitions: [{ id: "deepseek-tui" }],
+ selectedProvider: "deepseek-tui",
+ isModelLoading: false,
+ effectiveModelId: "",
+ availableModels: [],
+};
+
+function validate(overrides = {}) {
+ return validateDraftSubmission({
+ text: "hello",
+ allowsEmptyAutoSubmit: false,
+ composerState: baseComposerState,
+ autoSubmitConfig: null,
+ workspaceDirectory: "/tmp/project",
+ hasClient: true,
+ ...overrides,
+ });
+}
+
+describe("workspace draft agent model validation", () => {
+ test("allows a ready provider with no models to submit without a selected model", () => {
+ expect(validate({})).toBeNull();
+ });
+
+ test("keeps waiting while model defaults are loading", () => {
+ expect(
+ validate({
+ composerState: {
+ ...baseComposerState,
+ isModelLoading: true,
+ },
+ }),
+ ).toBe("Model defaults are still loading");
+ });
+
+ test("still requires a selected model when the provider exposes models", () => {
+ expect(
+ validate({
+ composerState: {
+ ...baseComposerState,
+ availableModels: [{ id: "deepseek/deepseek-v4-pro" }],
+ },
+ }),
+ ).toBe("No model is available for the selected provider");
+ });
+});
diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
index 20236d2c3..28442654e 100644
--- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
+++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
@@ -22,6 +22,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
+import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -73,51 +74,6 @@ function resolveAutoSubmitConfig(
};
}
-function validateDraftSubmission(input: {
- text: string;
- allowsEmptyAutoSubmit: boolean;
- composerState: {
- providerDefinitions: unknown[];
- selectedProvider: string | null;
- isModelLoading: boolean;
- effectiveModelId: string | null;
- };
- autoSubmitConfig: AutoSubmitConfig | null;
- workspaceDirectory: string | null;
- hasClient: boolean;
-}): string | null {
- const {
- text,
- allowsEmptyAutoSubmit,
- composerState,
- autoSubmitConfig,
- workspaceDirectory,
- hasClient,
- } = input;
- if (!allowsEmptyAutoSubmit && !text.trim()) {
- return "Initial prompt is required";
- }
- if (composerState.providerDefinitions.length === 0) {
- return "No available providers on the selected host";
- }
- if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
- return "Select a model";
- }
- if (composerState.isModelLoading) {
- return "Model defaults are still loading";
- }
- if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
- return "No model is available for the selected provider";
- }
- if (!workspaceDirectory) {
- return "Workspace directory not found";
- }
- if (!hasClient) {
- return "Host is not connected";
- }
- return null;
-}
-
function resolveDraftModeIdOverride(input: {
autoSubmitConfig: AutoSubmitConfig | null;
modeOptionsCount: number;
diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts
index 1f3e9fe30..b8f889c56 100644
--- a/packages/server/src/server/agent/providers/acp-agent.test.ts
+++ b/packages/server/src/server/agent/providers/acp-agent.test.ts
@@ -362,6 +362,33 @@ describe("createLoggedNdJsonStream", () => {
consoleError.mockRestore();
});
+ test("normalizes stringified numeric ACP response ids", async () => {
+ const input = new TransformStream();
+ const output = new TransformStream();
+ const logger = {
+ warn: vi.fn(),
+ };
+
+ const stream = createLoggedNdJsonStream(output.writable, input.readable, {
+ logger: asInternals>(logger),
+ provider: "deepseek-tui",
+ });
+ const reader = stream.readable.getReader();
+ const writer = input.writable.getWriter();
+
+ await writer.write(
+ new TextEncoder().encode('{"jsonrpc":"2.0","id":"0","result":{"ok":true}}\n'),
+ );
+
+ const parsed = await reader.read();
+
+ expect(parsed.value).toEqual({ jsonrpc: "2.0", id: 0, result: { ok: true } });
+ expect(logger.warn).not.toHaveBeenCalled();
+
+ await writer.close();
+ reader.releaseLock();
+ });
+
test("does not log terminal control sequences from malformed ACP stdout", async () => {
const input = new TransformStream();
const output = new TransformStream();
@@ -704,6 +731,31 @@ describe("ACPAgentSession Zed parity", () => {
expect(setSessionConfigOption).not.toHaveBeenCalled();
});
+ test("does not fail session start when configured model cannot be applied by ACP", async () => {
+ const logger = createTestLogger();
+ const childLogger = { trace: vi.fn(), warn: vi.fn() };
+ vi.spyOn(logger, "child").mockReturnValue(asInternals(childLogger));
+ const session = createSessionWithConfig(
+ { provider: "deepseek-tui", model: "deepseek/v4" },
+ logger,
+ );
+ const { internals, setSessionConfigOption, unstableSetSessionModel } =
+ prepareConfiguredOverrideSession(session, {
+ currentModel: null,
+ availableModels: null,
+ configOptions: [],
+ connection: { unstable_setSessionModel: undefined },
+ });
+
+ await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined();
+ expect(unstableSetSessionModel).not.toHaveBeenCalled();
+ expect(setSessionConfigOption).not.toHaveBeenCalled();
+ expect(childLogger.warn).toHaveBeenCalledWith(
+ { value: "deepseek/v4" },
+ "deepseek-tui does not expose ACP model selection; using provider default model",
+ );
+ });
+
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
const session = createSession();
const internals = asInternals(session);
diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts
index dbf2e3d02..583fe88b6 100644
--- a/packages/server/src/server/agent/providers/acp-agent.ts
+++ b/packages/server/src/server/agent/providers/acp-agent.ts
@@ -180,6 +180,26 @@ function summarizeMalformedACPStdoutError(error: unknown): { type: string; messa
};
}
+function normalizeACPIncomingMessage(message: AnyMessage): AnyMessage {
+ if (
+ "id" in message &&
+ !("method" in message) &&
+ typeof message.id === "string" &&
+ /^\d+$/.test(message.id)
+ ) {
+ const numericId = Number(message.id);
+ if (Number.isSafeInteger(numericId)) {
+ return {
+ ...message,
+ // COMPAT(deepseek-tui-acp-id): added v0.1.78, remove after 2026-11-19
+ // once the ACP SDK accepts stringified numeric response IDs.
+ id: numericId,
+ } as AnyMessage;
+ }
+ }
+ return message;
+}
+
export function createLoggedNdJsonStream(
output: NodeWritableStream,
input: NodeReadableStream,
@@ -214,7 +234,7 @@ export function createLoggedNdJsonStream(
try {
const message: AnyMessage = JSON.parse(trimmedLine);
- controller.enqueue(message);
+ controller.enqueue(normalizeACPIncomingMessage(message));
} catch (error) {
options.logger.warn(
{
@@ -1356,7 +1376,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
if (typeof this.connection.unstable_setSessionModel !== "function") {
- throw new Error(`${this.provider} does not expose ACP model selection`);
+ throw new Error(this.modelSelectionUnavailableMessage());
}
try {
@@ -1378,7 +1398,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const modelOption = selection.configOption;
if (!modelOption) {
- throw new Error(`${this.provider} does not expose ACP model selection`);
+ throw new Error(this.modelSelectionUnavailableMessage());
}
if (!selection.configChoice) {
this.warnInvalidSelection(
@@ -1884,7 +1904,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
availableModels: this.availableModels,
configOptions: this.configOptions,
});
- await this.setModelWithSelection({ modelId: configuredModelId, selection });
+ try {
+ await this.setModelWithSelection({ modelId: configuredModelId, selection });
+ } catch (error) {
+ if (!this.isModelSelectionUnavailableError(error)) {
+ throw error;
+ }
+ this.logger.warn(
+ { value: configuredModelId },
+ `${this.provider} does not expose ACP model selection; using provider default model`,
+ );
+ }
}
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
await this.setThinkingOption(this.config.thinkingOptionId);
@@ -1895,6 +1925,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.logger.warn({ value }, message);
}
+ private modelSelectionUnavailableMessage(): string {
+ return `${this.provider} does not expose ACP model selection`;
+ }
+
+ private isModelSelectionUnavailableError(error: unknown): boolean {
+ return error instanceof Error && error.message === this.modelSelectionUnavailableMessage();
+ }
+
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
switch (update.sessionUpdate) {
case "user_message_chunk": {