Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions app/components/demo/ModelPickerDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { Bot, Sparkles, Zap } from "lucide-vue-next";
import type { Component } from "vue";
import { ref } from "vue";
import { ModelPicker, type ModelPickerModel } from "~/registry/blocks/model-picker";

const selected = ref("openai/gpt-5-nano");

const models: ModelPickerModel[] = [
{
value: "openai/gpt-5-nano",
label: "GPT-5 Nano",
provider: "OpenAI",
description: "Fast everyday responses",
},
{
value: "anthropic/claude-haiku-4.5",
label: "Claude Haiku 4.5",
provider: "Anthropic",
description: "Concise writing and coding help",
},
{
value: "google/gemini-3-flash",
label: "Gemini 3 Flash",
provider: "Google",
description: "Multimodal reasoning",
},
{
value: "local/offline",
label: "Offline model",
provider: "Local",
description: "Disabled until downloaded",
disabled: true,
},
];

const icons: Partial<Record<string, Component>> = {
OpenAI: Sparkles,
Anthropic: Bot,
Google: Zap,
};

function providerIcon(model?: ModelPickerModel) {
return model?.provider ? icons[model.provider] : undefined;
}
</script>

<template>
<ModelPicker
v-model="selected"
:models="models"
>
<template #icon="{ model }">
<component
:is="providerIcon(model)"
v-if="providerIcon(model)"
class="size-4 shrink-0"
aria-hidden="true"
/>
</template>
</ModelPicker>
</template>
199 changes: 199 additions & 0 deletions app/registry/blocks/model-picker/ModelPicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { Check, ChevronDown, Search } from "@lucide/vue";
import { computed, nextTick, ref, watch } from "vue";
import { cn } from "@/lib/utils";

export interface ModelPickerModel {
/** Stable model value controlled by the consuming app. */
value: string;
/** Visible model label. */
label: string;
/** Optional supporting copy. */
description?: string;
/** Optional provider label supplied by the consuming app. */
provider?: string;
/** Prevent this option from being selected. */
disabled?: boolean;
}

export interface ModelPickerProps {
/** Controlled selected model value. */
modelValue?: string;
/** Models supplied by the consuming app. */
models?: ModelPickerModel[] | null;
/** Trigger placeholder when selected value is unknown. */
placeholder?: string;
/** Search input placeholder. */
searchPlaceholder?: string;
/** Empty state text. */
emptyLabel?: string;
/** Disable the picker. */
disabled?: boolean;
/** Align the menu against the trigger. */
align?: "start" | "end";
/** Additional CSS classes for the root element. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<ModelPickerProps>(), {
models: () => [],
placeholder: "Select model",
searchPlaceholder: "Search models...",
emptyLabel: "No models found.",
disabled: false,
align: "start",
});

const emit = defineEmits<{
"update:modelValue": [value: string];
}>();

const open = ref(false);
const query = ref("");
const searchRef = ref<HTMLInputElement>();

const modelItems = computed(() => props.models ?? []);
const selectedModel = computed(() => modelItems.value.find(model => model.value === props.modelValue));
const filteredModels = computed(() => {
const value = query.value.trim().toLowerCase();
if (!value) return modelItems.value;

return modelItems.value.filter((model) => {
return [model.label, model.description, model.provider]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(value);
});
});

function selectModel(model: ModelPickerModel) {
if (model.disabled) return;

emit("update:modelValue", model.value);
open.value = false;
}

watch(open, async (value) => {
if (value) {
await nextTick();
searchRef.value?.focus();
}
else {
query.value = "";
}
});
</script>

<template>
<div
data-slot="model-picker"
:class="cn('relative inline-block text-left', props.class)"
>
<button
type="button"
data-slot="model-picker-trigger"
class="inline-flex h-9 min-w-44 items-center justify-between gap-3 rounded-md border bg-background px-3 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
:aria-expanded="open"
aria-haspopup="listbox"
:disabled="disabled"
@click="open = !open"
>
<span class="flex min-w-0 items-center gap-2">
<slot
name="icon"
:model="selectedModel"
/>
<slot
name="selected"
:model="selectedModel"
>
<span class="truncate">{{ selectedModel?.label ?? placeholder }}</span>
</slot>
</span>
<ChevronDown
class="size-4 shrink-0 opacity-60"
aria-hidden="true"
/>
</button>

<div
v-if="open"
data-slot="model-picker-content"
:class="cn(
'absolute z-50 mt-2 w-72 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-xl',
align === 'end' ? 'right-0' : 'left-0',
)"
>
<div class="flex items-center gap-2 border-b px-3 py-2">
<Search
class="size-4 text-muted-foreground"
aria-hidden="true"
/>
<input
ref="searchRef"
v-model="query"
type="search"
:placeholder="searchPlaceholder"
class="h-8 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
>
</div>

<div
v-if="filteredModels.length"
data-slot="model-picker-list"
class="max-h-72 overflow-y-auto p-1"
role="listbox"
>
<button
v-for="model in filteredModels"
:key="model.value"
type="button"
data-slot="model-picker-option"
role="option"
:aria-selected="model.value === modelValue"
:disabled="model.disabled"
class="flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
@click="selectModel(model)"
>
<slot
name="icon"
:model="model"
/>
<slot
name="option"
:model="model"
:selected="model.value === modelValue"
>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{{ model.label }}</span>
<span
v-if="model.description || model.provider"
class="mt-0.5 block truncate text-xs text-muted-foreground"
>{{ model.description ?? model.provider }}</span>
</span>
<Check
class="mt-0.5 size-4 shrink-0"
:class="model.value === modelValue ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
</slot>
</button>
</div>

<div
v-else
data-slot="model-picker-empty"
class="p-6 text-center text-sm text-muted-foreground"
>
<slot
name="empty"
:query="query"
>
{{ emptyLabel }}
</slot>
</div>
</div>
</div>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/model-picker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ModelPicker, type ModelPickerModel, type ModelPickerProps } from "./ModelPicker.vue";
95 changes: 95 additions & 0 deletions content/docs/2.components/17.model-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: ModelPicker
description: Render a controlled model selector from app-owned model/provider data.
category: chat
---

::component-preview
---
name: ModelPickerDemo
---
::

## Installation

```bash
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/model-picker.json"
```

## Usage

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { ModelPicker, type ModelPickerModel } from '@/components/model-picker'

const selected = ref('openai/gpt-5-nano')

const models: ModelPickerModel[] = [
{ value: 'openai/gpt-5-nano', label: 'GPT-5 Nano', provider: 'OpenAI' },
{ value: 'anthropic/claude-haiku-4.5', label: 'Claude Haiku 4.5', provider: 'Anthropic' }
]
</script>

<template>
<ModelPicker v-model="selected" :models="models" />
</template>
```

## App-Owned Model State

`ModelPicker` is controlled UI. Your app owns model availability, provider metadata, persistence, cookies or local storage, pricing, and runtime selection behavior.

Pass model options through the `models` prop and handle `update:modelValue` through `v-model`. Use scoped slots for provider icons or custom option rendering instead of Nuxt Icon strings or hardcoded provider lists.

## Examples

### Controlled picker

::component-preview
---
name: ModelPickerDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` | `string` | — | Controlled selected model value. |
| `models` | `ModelPickerModel[] \| null` | `[]` | Models supplied by your app. |
| `placeholder` | `string` | `'Select model'` | Trigger placeholder when selected value is unknown. |
| `searchPlaceholder` | `string` | `'Search models...'` | Search input placeholder. |
| `emptyLabel` | `string` | `'No models found.'` | Empty state text. |
| `disabled` | `boolean` | `false` | Disable the picker. |
| `align` | `'start' \| 'end'` | `'start'` | Align the menu against the trigger. |
| `class` | `string` | — | Additional CSS classes for the root element. |

### Types

```ts
interface ModelPickerModel {
value: string
label: string
description?: string
provider?: string
disabled?: boolean
}
```

### Events

| Event | Payload | Description |
|------|---------|-------------|
| `update:modelValue` | `[value: string]` | Emitted when a selectable model is chosen. |

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `icon` | `{ model?: ModelPickerModel }` | Optional icon for the selected model and option rows. |
| `selected` | `{ model?: ModelPickerModel }` | Replace trigger selected-model content. |
| `option` | `{ model: ModelPickerModel, selected: boolean }` | Replace option row content. |
| `empty` | `{ query: string }` | Replace empty state content. |
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: [],
registryDependencies: [],
},
"model-picker": {
dependencies: ["@lucide/vue"],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading