summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRonan Kearns <[email protected]>2026-01-21 10:25:34 -0500
committerGitHub <[email protected]>2026-01-21 09:25:34 -0600
commit6ac8c85b3441ddd5522d37e3e28cabc604d34d70 (patch)
tree25ebc49f540b68ef8f3478a24e55f9925706f16a
parent19f68382fd3501d1abc3ca9c49aee61f2c01fdb4 (diff)
downloadopencode-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.tsx12
-rw-r--r--packages/app/src/components/dialog-select-model.tsx13
-rw-r--r--packages/app/src/components/model-tooltip.tsx70
-rw-r--r--packages/ui/src/components/list.tsx69
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>