diff options
| author | Ronan Kearns <[email protected]> | 2026-01-21 10:25:34 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-21 09:25:34 -0600 |
| commit | 6ac8c85b3441ddd5522d37e3e28cabc604d34d70 (patch) | |
| tree | 25ebc49f540b68ef8f3478a24e55f9925706f16a | |
| parent | 19f68382fd3501d1abc3ca9c49aee61f2c01fdb4 (diff) | |
| download | opencode-6ac8c85b3441ddd5522d37e3e28cabc604d34d70.tar.gz opencode-6ac8c85b3441ddd5522d37e3e28cabc604d34d70.zip | |
feat(app): model tooltip metadata in chooser (per Figma request) (#9707)
| -rw-r--r-- | packages/app/src/components/dialog-select-model-unpaid.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/components/dialog-select-model.tsx | 13 | ||||
| -rw-r--r-- | packages/app/src/components/model-tooltip.tsx | 70 | ||||
| -rw-r--r-- | packages/ui/src/components/list.tsx | 69 |
4 files changed, 132 insertions, 32 deletions
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 98cf57508..208e90d17 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -5,11 +5,13 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { type Component, onCleanup, onMount, Show } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" +import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" export const DialogSelectModelUnpaid: Component = () => { @@ -40,6 +42,16 @@ export const DialogSelectModelUnpaid: Component = () => { items={local.model.list} current={local.model.current()} 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) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 8288a8255..dd599e143 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -8,8 +8,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogManageModels } from "./dialog-manage-models" +import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" const ModelList: Component<{ @@ -28,6 +30,7 @@ const ModelList: Component<{ .filter((m) => (props.provider ? m.provider.id === props.provider : true)), ) + return ( <List 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 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) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, diff --git a/packages/app/src/components/model-tooltip.tsx b/packages/app/src/components/model-tooltip.tsx new file mode 100644 index 000000000..14b4ba799 --- /dev/null +++ b/packages/app/src/components/model-tooltip.tsx @@ -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> + ) +} diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index fc9fa5405..6d7ad1da6 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -24,6 +24,7 @@ export interface ListProps<T> extends FilteredListProps<T> { activeIcon?: IconProps["name"] filter?: string search?: ListSearchProps | boolean + itemWrapper?: (item: T, node: JSX.Element) => JSX.Element } export interface ListRef { @@ -245,39 +246,43 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) </Show> <div data-slot="list-items"> <For each={group.items}> - {(item, i) => ( - <button - data-slot="list-item" - data-key={props.key(item)} - data-active={props.key(item) === active()} - data-selected={item === props.current} - onClick={() => handleSelect(item, i())} - type="button" - onMouseMove={(event) => { - if (!moved(event)) return - setStore("mouseActive", true) - setActive(props.key(item)) - }} - onMouseLeave={() => { - if (!store.mouseActive) return - setActive(null) - }} - > - {props.children(item)} - <Show when={item === props.current}> - <span data-slot="list-item-selected-icon"> - <Icon name="check-small" /> - </span> - </Show> - <Show when={props.activeIcon}> - {(icon) => ( - <span data-slot="list-item-active-icon"> - <Icon name={icon()} /> + {(item, i) => { + const node = ( + <button + data-slot="list-item" + data-key={props.key(item)} + data-active={props.key(item) === active()} + data-selected={item === props.current} + onClick={() => handleSelect(item, i())} + type="button" + onMouseMove={(event) => { + if (!moved(event)) return + setStore("mouseActive", true) + setActive(props.key(item)) + }} + onMouseLeave={() => { + if (!store.mouseActive) return + setActive(null) + }} + > + {props.children(item)} + <Show when={item === props.current}> + <span data-slot="list-item-selected-icon"> + <Icon name="check-small" /> </span> - )} - </Show> - </button> - )} + </Show> + <Show when={props.activeIcon}> + {(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> </div> </div> |
