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) => ( + } + > + {node} + + )} 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 ( ( + } + > + {node} + + )} 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 + +type ModelInfo = { + id: string + name: string + provider: { + name: string + } + capabilities?: { + reasoning: boolean + input: InputMap + } + modalities?: { + input: Array + } + 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 = [] + 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 = ["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 ( + + {title()} + + {(value) => Allows: {value()}} + + {reasoning()} + {context()} + + ) +} 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 extends FilteredListProps { 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(props: ListProps & { ref?: (ref: ListRef) => void }) - {(item, i) => ( - 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)} - - - - - - - {(icon) => ( - - + {(item, i) => { + const node = ( + 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)} + + + - )} - - - )} + + + {(icon) => ( + + + + )} + + + ) + if (props.itemWrapper) return props.itemWrapper(item, node) + return node + }}