feat(app): model tooltip metadata in chooser (per Figma request) (#9707)

This commit is contained in:
Ronan Kearns
2026-01-21 10:25:34 -05:00
committed by GitHub
parent 19f68382fd
commit 6ac8c85b34
4 changed files with 132 additions and 32 deletions

View File

@@ -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,

View File

@@ -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,

View 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>
)
}

View File

@@ -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>