feat(app): model tooltip metadata in chooser (per Figma request) (#9707)
This commit is contained in:
@@ -5,11 +5,13 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|||||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component = () => {
|
export const DialogSelectModelUnpaid: Component = () => {
|
||||||
@@ -40,6 +42,16 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
items={local.model.list}
|
items={local.model.list}
|
||||||
current={local.model.current()}
|
current={local.model.current()}
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
|
itemWrapper={(item, node) => (
|
||||||
|
<Tooltip
|
||||||
|
class="w-full"
|
||||||
|
placement="right-start"
|
||||||
|
gutter={12}
|
||||||
|
value={<ModelTooltip model={item} latest={item.latest} free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)} />}
|
||||||
|
>
|
||||||
|
{node}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { DialogManageModels } from "./dialog-manage-models"
|
import { DialogManageModels } from "./dialog-manage-models"
|
||||||
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
@@ -28,6 +30,7 @@ const ModelList: Component<{
|
|||||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||||
@@ -46,6 +49,16 @@ const ModelList: Component<{
|
|||||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||||
}}
|
}}
|
||||||
|
itemWrapper={(item, node) => (
|
||||||
|
<Tooltip
|
||||||
|
class="w-full"
|
||||||
|
placement="right-start"
|
||||||
|
gutter={12}
|
||||||
|
value={<ModelTooltip model={item} latest={item.latest} free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)} />}
|
||||||
|
>
|
||||||
|
{node}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
|
|||||||
70
packages/app/src/components/model-tooltip.tsx
Normal file
70
packages/app/src/components/model-tooltip.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Show, type Component } from "solid-js"
|
||||||
|
|
||||||
|
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||||
|
type InputMap = Record<InputKey, boolean>
|
||||||
|
|
||||||
|
type ModelInfo = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
capabilities?: {
|
||||||
|
reasoning: boolean
|
||||||
|
input: InputMap
|
||||||
|
}
|
||||||
|
modalities?: {
|
||||||
|
input: Array<string>
|
||||||
|
}
|
||||||
|
reasoning?: boolean
|
||||||
|
limit: {
|
||||||
|
context: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceName(model: ModelInfo) {
|
||||||
|
const value = `${model.id} ${model.name}`.toLowerCase()
|
||||||
|
|
||||||
|
if (/claude|anthropic/.test(value)) return "Anthropic"
|
||||||
|
if (/gpt|o[1-4]|codex|openai/.test(value)) return "OpenAI"
|
||||||
|
if (/gemini|palm|bard|google/.test(value)) return "Google"
|
||||||
|
if (/grok|xai/.test(value)) return "xAI"
|
||||||
|
if (/llama|meta/.test(value)) return "Meta"
|
||||||
|
|
||||||
|
return model.provider.name
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
|
||||||
|
const title = () => {
|
||||||
|
const tags: Array<string> = []
|
||||||
|
if (props.latest) tags.push("Latest")
|
||||||
|
if (props.free) tags.push("Free")
|
||||||
|
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||||
|
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||||
|
}
|
||||||
|
const inputs = () => {
|
||||||
|
if (props.model.capabilities) {
|
||||||
|
const input = props.model.capabilities.input
|
||||||
|
const order: Array<InputKey> = ["text", "image", "audio", "video", "pdf"]
|
||||||
|
const entries = order.filter((key) => input[key])
|
||||||
|
return entries.length ? entries.join(", ") : undefined
|
||||||
|
}
|
||||||
|
return props.model.modalities?.input?.join(", ")
|
||||||
|
}
|
||||||
|
const reasoning = () => {
|
||||||
|
if (props.model.capabilities) return props.model.capabilities.reasoning ? "Allows reasoning" : "No reasoning"
|
||||||
|
return props.model.reasoning ? "Allows reasoning" : "No reasoning"
|
||||||
|
}
|
||||||
|
const context = () => `Context limit ${props.model.limit.context.toLocaleString()}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1 py-1">
|
||||||
|
<div class="text-13-medium">{title()}</div>
|
||||||
|
<Show when={inputs()}>
|
||||||
|
{(value) => <div class="text-12-regular text-text-invert-base">Allows: {value()}</div>}
|
||||||
|
</Show>
|
||||||
|
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||||
|
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
|
|||||||
activeIcon?: IconProps["name"]
|
activeIcon?: IconProps["name"]
|
||||||
filter?: string
|
filter?: string
|
||||||
search?: ListSearchProps | boolean
|
search?: ListSearchProps | boolean
|
||||||
|
itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListRef {
|
export interface ListRef {
|
||||||
@@ -245,39 +246,43 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
|||||||
</Show>
|
</Show>
|
||||||
<div data-slot="list-items">
|
<div data-slot="list-items">
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item, i) => (
|
{(item, i) => {
|
||||||
<button
|
const node = (
|
||||||
data-slot="list-item"
|
<button
|
||||||
data-key={props.key(item)}
|
data-slot="list-item"
|
||||||
data-active={props.key(item) === active()}
|
data-key={props.key(item)}
|
||||||
data-selected={item === props.current}
|
data-active={props.key(item) === active()}
|
||||||
onClick={() => handleSelect(item, i())}
|
data-selected={item === props.current}
|
||||||
type="button"
|
onClick={() => handleSelect(item, i())}
|
||||||
onMouseMove={(event) => {
|
type="button"
|
||||||
if (!moved(event)) return
|
onMouseMove={(event) => {
|
||||||
setStore("mouseActive", true)
|
if (!moved(event)) return
|
||||||
setActive(props.key(item))
|
setStore("mouseActive", true)
|
||||||
}}
|
setActive(props.key(item))
|
||||||
onMouseLeave={() => {
|
}}
|
||||||
if (!store.mouseActive) return
|
onMouseLeave={() => {
|
||||||
setActive(null)
|
if (!store.mouseActive) return
|
||||||
}}
|
setActive(null)
|
||||||
>
|
}}
|
||||||
{props.children(item)}
|
>
|
||||||
<Show when={item === props.current}>
|
{props.children(item)}
|
||||||
<span data-slot="list-item-selected-icon">
|
<Show when={item === props.current}>
|
||||||
<Icon name="check-small" />
|
<span data-slot="list-item-selected-icon">
|
||||||
</span>
|
<Icon name="check-small" />
|
||||||
</Show>
|
|
||||||
<Show when={props.activeIcon}>
|
|
||||||
{(icon) => (
|
|
||||||
<span data-slot="list-item-active-icon">
|
|
||||||
<Icon name={icon()} />
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</Show>
|
||||||
</Show>
|
<Show when={props.activeIcon}>
|
||||||
</button>
|
{(icon) => (
|
||||||
)}
|
<span data-slot="list-item-active-icon">
|
||||||
|
<Icon name={icon()} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
if (props.itemWrapper) return props.itemWrapper(item, node)
|
||||||
|
return node
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user