Explorar el Código

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

Ronan Kearns hace 1 mes
padre
commit
6ac8c85b34

+ 12 - 0
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,

+ 13 - 0
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,

+ 70 - 0
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>
+  )
+}

+ 37 - 32
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>