Просмотр исходного кода

feat(app): added command palette (#2630)

Co-authored-by: Adam <[email protected]>
Filip 5 месяцев назад
Родитель
Сommit
d3b6545e7c

+ 1 - 1
packages/app/src/components/code.tsx

@@ -279,7 +279,7 @@ export function Code(props: Props) {
       }}
       innerHTML={html()}
       class="
-          font-mono text-xs tracking-wide overflow-y-auto no-scrollbar h-full
+          font-mono text-xs tracking-wide overflow-y-auto h-full
           [&]:[counter-reset:line]
           [&_pre]:focus-visible:outline-none
           [&_pre]:overflow-x-auto [&_pre]:no-scrollbar

+ 216 - 0
packages/app/src/components/select-dialog.tsx

@@ -0,0 +1,216 @@
+import { createEffect, Show, For, createMemo, type JSX } from "solid-js"
+import { Dialog } from "@kobalte/core/dialog"
+import { Icon, IconButton } from "@/ui"
+import { createStore } from "solid-js/store"
+import { entries, flatMap, groupBy, map, mapValues, pipe } from "remeda"
+import { createList } from "solid-list"
+import fuzzysort from "fuzzysort"
+
+interface SelectDialogProps<T> {
+  items: T[]
+  key: (item: T) => string
+  render: (item: T) => JSX.Element
+  current?: T
+  placeholder?: string
+  filter?:
+    | false
+    | {
+        keys: string[]
+      }
+  groupBy?: (x: T) => string
+  onSelect?: (value: T | undefined) => void
+  onClose?: () => void
+}
+
+export function SelectDialog<T>(props: SelectDialogProps<T>) {
+  let scrollRef: HTMLDivElement | undefined
+  const [store, setStore] = createStore({
+    filter: "",
+    mouseActive: false,
+  })
+
+  const grouped = createMemo(() => {
+    const needle = store.filter.toLowerCase()
+    const result = pipe(
+      props.items,
+      (x) =>
+        !needle || !props.filter
+          ? x
+          : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
+      groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+      mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
+      entries(),
+      map(([k, v]) => ({ category: k, items: v })),
+    )
+    return result
+  })
+  const flat = createMemo(() => {
+    return pipe(
+      grouped(),
+      flatMap((x) => x.items),
+    )
+  })
+  const list = createList({
+    items: () => flat().map(props.key),
+    initialActive: props.current ? props.key(props.current) : undefined,
+    loop: true,
+  })
+  const resetSelection = () => list.setActive(props.key(flat()[0]))
+
+  createEffect(() => {
+    store.filter
+    scrollRef?.scrollTo(0, 0)
+    resetSelection()
+  })
+
+  createEffect(() => {
+    if (store.mouseActive) return
+    if (list.active() === props.key(flat()[0])) {
+      scrollRef?.scrollTo(0, 0)
+      return
+    }
+    const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
+    element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+  })
+
+  const handleInput = (value: string) => {
+    setStore("filter", value)
+    resetSelection()
+  }
+
+  const handleSelect = (item: T) => {
+    props.onSelect?.(item)
+    props.onClose?.()
+  }
+
+  const handleKey = (e: KeyboardEvent) => {
+    setStore("mouseActive", false)
+
+    if (e.key === "Enter") {
+      e.preventDefault()
+      const selected = flat().find((x) => props.key(x) === list.active())
+      if (selected) handleSelect(selected)
+    } else if (e.key === "Escape") {
+      e.preventDefault()
+      props.onClose?.()
+    } else {
+      list.onKeyDown(e)
+    }
+  }
+
+  return (
+    <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
+      <Dialog.Portal>
+        <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
+        <Dialog.Content
+          class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl 
+                 shadow-[0_0_33px_rgba(0,0,0,0.8)]
+                 bg-background border border-border-subtle/30 rounded-lg  z-[101]
+                 max-h-[60vh] flex flex-col"
+        >
+          <div class="border-b border-border-subtle/30">
+            <div class="relative">
+              <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
+              <input
+                type="text"
+                value={store.filter}
+                onInput={(e) => handleInput(e.currentTarget.value)}
+                onKeyDown={handleKey}
+                placeholder={props.placeholder}
+                class="w-full pl-10 pr-4 py-2 rounded-t-md
+                       text-sm text-text placeholder-text-muted/70
+                       focus:outline-none"
+                autofocus
+                spellcheck={false}
+                autocorrect="off"
+                autocomplete="off"
+                autocapitalize="off"
+              />
+              <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
+                {/* <Show when={fileResults.loading && mode() === "files"}>
+                  <div class="text-text-muted">
+                    <Icon name="refresh" size={14} class="animate-spin" />
+                  </div>
+                </Show> */}
+                <Show when={store.filter}>
+                  <IconButton
+                    size="xs"
+                    variant="ghost"
+                    class="text-text-muted hover:text-text"
+                    onClick={() => {
+                      setStore("filter", "")
+                      resetSelection()
+                    }}
+                  >
+                    <Icon name="close" size={14} />
+                  </IconButton>
+                </Show>
+              </div>
+            </div>
+          </div>
+          <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
+            <Show
+              when={flat().length > 0}
+              fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
+            >
+              <For each={grouped()}>
+                {(group) => (
+                  <>
+                    <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
+                      {group.category}
+                    </div>
+                    <div class="p-2">
+                      <For each={group.items}>
+                        {(item) => (
+                          <button
+                            data-key={props.key(item)}
+                            onClick={() => handleSelect(item)}
+                            onMouseMove={() => {
+                              setStore("mouseActive", true)
+                              list.setActive(props.key(item))
+                            }}
+                            classList={{
+                              "w-full px-3 py-2 flex items-center gap-3": true,
+                              "rounded-md text-left transition-colors group": true,
+                              "bg-background-element": props.key(item) === list.active(),
+                              "hover:bg-background-element": true,
+                            }}
+                          >
+                            {props.render(item)}
+                          </button>
+                        )}
+                      </For>
+                    </div>
+                  </>
+                )}
+              </For>
+            </Show>
+          </div>
+          <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
+            <div class="flex items-center gap-5">
+              <span class="flex items-center gap-1.5">
+                <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
+                  ↑↓
+                </kbd>
+                Navigate
+              </span>
+              <span class="flex items-center gap-1.5">
+                <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
+                  ↵
+                </kbd>
+                Select
+              </span>
+              <span class="flex items-center gap-1.5">
+                <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
+                  ESC
+                </kbd>
+                Close
+              </span>
+            </div>
+            <span>{`${flat().length} results`}</span>
+          </div>
+        </Dialog.Content>
+      </Dialog.Portal>
+    </Dialog>
+  )
+}

+ 9 - 90
packages/app/src/components/select.tsx

@@ -1,46 +1,26 @@
 import { Select as KobalteSelect } from "@kobalte/core/select"
-import { createEffect, createMemo, Show } from "solid-js"
+import { createMemo } from "solid-js"
 import type { ComponentProps } from "solid-js"
 import { Icon } from "@/ui/icon"
-import fuzzysort from "fuzzysort"
 import { pipe, groupBy, entries, map } from "remeda"
-import { createStore } from "solid-js/store"
+import { Button, type ButtonProps } from "@/ui"
 
 export interface SelectProps<T> {
-  variant?: "default" | "outline"
-  size?: "sm" | "md" | "lg"
   placeholder?: string
-  filter?:
-    | false
-    | {
-        placeholder?: string
-        keys: string[]
-      }
   options: T[]
   current?: T
   value?: (x: T) => string
   label?: (x: T) => string
   groupBy?: (x: T) => string
-  onFilter?: (query: string) => void
   onSelect?: (value: T | undefined) => void
   class?: ComponentProps<"div">["class"]
   classList?: ComponentProps<"div">["classList"]
 }
 
-export function Select<T>(props: SelectProps<T>) {
-  let inputRef: HTMLInputElement | undefined = undefined
-  let listboxRef: HTMLUListElement | undefined = undefined
-  const [store, setStore] = createStore({
-    filter: "",
-  })
+export function Select<T>(props: SelectProps<T> & ButtonProps) {
   const grouped = createMemo(() => {
-    const needle = store.filter.toLowerCase()
     const result = pipe(
       props.options,
-      (x) =>
-        !needle || !props.filter
-          ? x
-          : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
       groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
       // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
       entries(),
@@ -48,19 +28,6 @@ export function Select<T>(props: SelectProps<T>) {
     )
     return result
   })
-  // const flat = createMemo(() => {
-  //   return pipe(
-  //     grouped(),
-  //     flatMap(({ options }) => options),
-  //   )
-  // })
-
-  createEffect(() => {
-    store.filter
-    listboxRef?.scrollTo(0, 0)
-    // setStore("selected", 0)
-    // scroll.scrollTo(0)
-  })
 
   return (
     <KobalteSelect<T, { category: string; options: T[] }>
@@ -89,36 +56,21 @@ export function Select<T>(props: SelectProps<T>) {
           <KobalteSelect.ItemLabel>
             {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
           </KobalteSelect.ItemLabel>
-          <KobalteSelect.ItemIndicator
-            classList={{
-              "ml-auto": true,
-            }}
-          >
+          <KobalteSelect.ItemIndicator class="ml-auto">
             <Icon name="checkmark" size={16} />
           </KobalteSelect.ItemIndicator>
         </KobalteSelect.Item>
       )}
       onChange={(v) => {
-        if (props.onSelect) props.onSelect(v ?? undefined)
-        if (v !== null) {
-          // close the select
-        }
+        props.onSelect?.(v ?? undefined)
       }}
-      onOpenChange={(v) => v || setStore("filter", "")}
     >
       <KobalteSelect.Trigger
+        as={Button}
+        size={props.size || "sm"}
+        variant={props.variant || "secondary"}
         classList={{
           ...(props.classList ?? {}),
-          "flex w-full items-center justify-between rounded-md transition-colors": true,
-          "focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true,
-          "disabled:cursor-not-allowed disabled:opacity-50": true,
-          "data-[placeholder-shown]:text-text-muted cursor-pointer": true,
-          "hover:bg-background-element focus-visible:ring-border-active": true,
-          "bg-background-element text-text": props.variant === "default" || !props.variant,
-          "border-2 border-border bg-transparent text-text": props.variant === "outline",
-          "h-6 pl-2 text-xs": props.size === "sm",
-          "h-8 pl-3 text-sm": props.size === "md" || !props.size,
-          "h-10 pl-4 text-base": props.size === "lg",
           [props.class ?? ""]: !!props.class,
         }}
       >
@@ -140,13 +92,6 @@ export function Select<T>(props: SelectProps<T>) {
       </KobalteSelect.Trigger>
       <KobalteSelect.Portal>
         <KobalteSelect.Content
-          onKeyDown={(e) => {
-            if (!props.filter) return
-            if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
-              return
-            }
-            inputRef?.focus()
-          }}
           classList={{
             "min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
             "bg-background-panel p-1 shadow-md z-50": true,
@@ -154,33 +99,7 @@ export function Select<T>(props: SelectProps<T>) {
             "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
           }}
         >
-          <Show when={props.filter}>
-            <input
-              ref={(el) => (inputRef = el)}
-              id="select-filter"
-              type="text"
-              placeholder={props.filter ? props.filter.placeholder : "Filter items"}
-              value={store.filter}
-              onInput={(e) => setStore("filter", e.currentTarget.value)}
-              onKeyDown={(e) => {
-                if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
-                  e.preventDefault()
-                  e.stopPropagation()
-                  listboxRef?.focus()
-                }
-              }}
-              classList={{
-                "w-full": true,
-                "px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true,
-              }}
-            />
-          </Show>
-          <KobalteSelect.Listbox
-            ref={(el) => (listboxRef = el)}
-            classList={{
-              "overflow-y-auto max-h-48 no-scrollbar": true,
-            }}
-          />
+          <KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
         </KobalteSelect.Content>
       </KobalteSelect.Portal>
     </KobalteSelect>

+ 1 - 1
packages/app/src/components/session-list.tsx

@@ -7,7 +7,7 @@ export default function SessionList() {
   const local = useLocal()
 
   return (
-    <VList data={sync.data.session} class="p-2 no-scrollbar">
+    <VList data={sync.data.session} class="p-2">
       {(session) => (
         <Tooltip placement="right" value={session.title} class="w-full min-w-0">
           <Button

+ 20 - 34
packages/app/src/context/local.tsx

@@ -1,7 +1,7 @@
 import { createStore, produce, reconcile } from "solid-js/store"
 import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
 import { uniqueBy } from "remeda"
-import type { FileContent, FileNode } from "@opencode-ai/sdk"
+import type { FileContent, FileNode, Model, Provider } from "@opencode-ai/sdk"
 import { useSDK, useEvent, useSync } from "@/context"
 
 export type LocalFile = FileNode &
@@ -19,12 +19,17 @@ export type LocalFile = FileNode &
 export type TextSelection = LocalFile["selection"]
 export type View = LocalFile["view"]
 
+export type LocalModel = Omit<Model, "provider"> & {
+  provider: Provider
+}
+export type ModelKey = { providerID: string; modelID: string }
+
 function init() {
   const sdk = useSDK()
   const sync = useSync()
 
-  const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
   const agent = (() => {
+    const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
     const [store, setStore] = createStore<{
       current: string
     }>({
@@ -54,18 +59,14 @@ function init() {
   })()
 
   const model = (() => {
+    const list = createMemo(() =>
+      sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
+    )
+    const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
+
     const [store, setStore] = createStore<{
-      model: Record<
-        string,
-        {
-          providerID: string
-          modelID: string
-        }
-      >
-      recent: {
-        providerID: string
-        modelID: string
-      }[]
+      model: Record<string, ModelKey>
+      recent: ModelKey[]
     }>({
       model: {},
       recent: [],
@@ -81,37 +82,21 @@ function init() {
       if (store.recent.length) return store.recent[0]
       const provider = sync.data.provider[0]
       const model = Object.values(provider.models)[0]
-      return {
-        providerID: provider.id,
-        modelID: model.id,
-      }
+      return { modelID: model.id, providerID: provider.id }
     })
 
     const current = createMemo(() => {
       const a = agent.current()
-      return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
+      return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
     })
 
-    const list = createMemo(() =>
-      sync.data.provider.flatMap((x) => Object.values(x.models).map((m) => ({ providerID: x.id, modelID: m.id }))),
-    )
+    const recent = createMemo(() => store.recent.map(find).filter(Boolean))
 
     return {
       list,
       current,
-      recent() {
-        return store.recent
-      },
-      parsed: createMemo(() => {
-        const value = current()
-        const provider = sync.data.provider.find((x) => x.id === value.providerID)!
-        const model = provider.models[value.modelID]
-        return {
-          provider: provider.name ?? value.providerID,
-          model: model.name ?? value.modelID,
-        }
-      }),
-      set(model: { providerID: string; modelID: string } | undefined, options?: { recent?: boolean }) {
+      recent,
+      set(model: ModelKey | undefined, options?: { recent?: boolean }) {
         batch(() => {
           setStore("model", agent.current().name, model ?? fallback())
           if (options?.recent && model) {
@@ -234,6 +219,7 @@ function init() {
           break
         case "file.watcher.updated":
           load(event.properties.file)
+          sync.load.changes()
           break
       }
     })

+ 18 - 13
packages/app/src/context/sync.tsx

@@ -94,20 +94,24 @@ function init() {
   })
 
   const sdk = useSDK()
-  Promise.all([
-    sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
-    sdk.path.get().then((x) => setStore("path", x.data!)),
-    sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
-    sdk.session.list().then((x) =>
-      setStore(
-        "session",
-        (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
+
+  const load = {
+    provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
+    path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
+    agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+    session: () =>
+      sdk.session.list().then((x) =>
+        setStore(
+          "session",
+          (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
+        ),
       ),
-    ),
-    sdk.config.get().then((x) => setStore("config", x.data!)),
-    sdk.file.status().then((x) => setStore("changes", x.data!)),
-    sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
-  ]).then(() => setStore("ready", true))
+    config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
+    changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
+    node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
+  }
+
+  Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
 
   return {
     data: store,
@@ -138,6 +142,7 @@ function init() {
         )
       },
     },
+    load,
   }
 }
 

+ 13 - 0
packages/app/src/index.css

@@ -19,6 +19,19 @@
     /* color: var(--color-background); */
   }
 
+  ::-webkit-scrollbar-track {
+    background: var(--theme-background-panel);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: var(--theme-border-subtle);
+    border-radius: 6px;
+  }
+
+  * {
+    scrollbar-color: var(--theme-border-subtle) var(--theme-background-panel);
+  }
+
   .prose h1 {
     color: var(--color-text);
     font-size: var(--text-sm);

+ 72 - 20
packages/app/src/pages/index.tsx

@@ -1,8 +1,9 @@
-import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
+import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
 import { Tabs } from "@/ui/tabs"
 import { Select } from "@/components/select"
 import FileTree from "@/components/file-tree"
 import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
+import { SelectDialog } from "@/components/select-dialog"
 import { useLocal, useSDK } from "@/context"
 import { Code } from "@/components/code"
 import {
@@ -28,6 +29,7 @@ export default function Page() {
     activeItem: undefined as string | undefined,
     prompt: "",
     dragging: undefined as "left" | "right" | undefined,
+    modelSelectOpen: false,
   })
 
   let inputRef: HTMLInputElement | undefined = undefined
@@ -43,6 +45,17 @@ export default function Page() {
   })
 
   const handleKeyDown = (e: KeyboardEvent) => {
+    if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
+      e.preventDefault()
+      setStore("modelSelectOpen", true)
+      return
+    }
+    if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
+      e.preventDefault()
+      setStore("modelSelectOpen", true)
+      return
+    }
+
     const inputFocused = document.activeElement === inputRef
     if (inputFocused) {
       if (e.key === "Escape") {
@@ -190,7 +203,7 @@ export default function Page() {
       path: { id: session!.id },
       body: {
         agent: local.agent.current()!.name,
-        model: local.model.current(),
+        model: { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id },
         parts: [
           {
             type: "text",
@@ -265,7 +278,7 @@ export default function Page() {
           class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
           style={`width: ${local.layout.rightWidth()}px`}
         >
-          <div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar">
+          <div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
             <Show when={local.session.active()} fallback={<SessionList />}>
               {(activeSession) => (
                 <div class="relative">
@@ -470,7 +483,7 @@ export default function Page() {
               type="text"
               value={store.prompt}
               onInput={(e) => setStore("prompt", e.currentTarget.value)}
-              placeholder="It all starts with a prompt..."
+              placeholder="Placeholder text..."
               class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
             />
             <div class="flex justify-between items-center text-xs text-text-muted">
@@ -479,24 +492,13 @@ export default function Page() {
                   options={local.agent.list().map((a) => a.name)}
                   current={local.agent.current().name}
                   onSelect={local.agent.set}
-                  size="sm"
                   class="uppercase"
                 />
-                <Select
-                  options={local.model.list()}
-                  current={local.model.current()}
-                  onSelect={local.model.set}
-                  label={(x) => x.modelID}
-                  value={(x) => `${x.providerID}.${x.modelID}`}
-                  filter={{
-                    keys: ["providerID", "modelID"],
-                    placeholder: "Filter models",
-                  }}
-                  groupBy={(x) => x.providerID}
-                  size="sm"
-                  class="uppercase"
-                />
-                <span class="text-text-muted/70">{local.model.parsed().provider}</span>
+                <Button onClick={() => setStore("modelSelectOpen", true)}>
+                  {local.model.current()?.name ?? "Select model"}
+                  <Icon name="chevron-down" size={24} class="text-text-muted" />
+                </Button>
+                <span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
               </div>
               <div class="flex gap-1 items-center">
                 <IconButton class="text-text-muted" size="xs" variant="ghost">
@@ -510,6 +512,56 @@ export default function Page() {
           </div>
         </form>
       </div>
+      <Show when={store.modelSelectOpen}>
+        <SelectDialog
+          key={(x) => `${x.provider.id}:${x.id}`}
+          items={local.model.list()}
+          current={local.model.current()}
+          render={(i) => (
+            <div class="w-full flex items-center justify-between">
+              <div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
+                <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
+                <span class="text-xs text-text whitespace-nowrap">{i.name}</span>
+                <span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+                  {i.id}
+                </span>
+              </div>
+              <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
+                <Tooltip forceMount={false} value="Reasoning">
+                  <Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
+                </Tooltip>
+                <Tooltip forceMount={false} value="Tools">
+                  <Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
+                </Tooltip>
+                <Tooltip forceMount={false} value="Attachments">
+                  <Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
+                </Tooltip>
+                <div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
+                  {new Intl.NumberFormat("en-US", {
+                    notation: "compact",
+                    compactDisplay: "short",
+                  }).format(i.limit.context)}
+                </div>
+                <Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
+                  <div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
+                    <Switch fallback="FREE">
+                      <Match when={i.cost?.input > 10}>$$$</Match>
+                      <Match when={i.cost?.input > 1}>$$</Match>
+                      <Match when={i.cost?.input > 0.1}>$</Match>
+                    </Switch>
+                  </div>
+                </Tooltip>
+              </div>
+            </div>
+          )}
+          filter={{
+            keys: ["provider.name", "name", "id"],
+          }}
+          groupBy={(x) => x.provider.name}
+          onClose={() => setStore("modelSelectOpen", false)}
+          onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
+        />
+      </Show>
     </div>
   )
 }

+ 27 - 40
packages/app/src/ui/button.tsx

@@ -1,49 +1,36 @@
-import { Button as KobalteButton } from "@kobalte/core/button"
-import { splitProps } from "solid-js"
-import type { ComponentProps } from "solid-js"
+import { Button as Kobalte } from "@kobalte/core/button"
+import { type ComponentProps, splitProps } from "solid-js"
 
-export interface ButtonProps extends ComponentProps<typeof KobalteButton> {
-  variant?: "primary" | "secondary" | "outline" | "ghost"
+export interface ButtonProps {
+  variant?: "primary" | "secondary" | "ghost"
   size?: "sm" | "md" | "lg"
 }
 
-export const buttonStyles = {
-  base: "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
-  variants: {
-    primary: "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50",
-    secondary:
-      "bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50",
-    outline:
-      "border border-border bg-transparent text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted",
-    ghost: "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted",
-  },
-  sizes: {
-    sm: "h-8 px-3 text-sm",
-    md: "h-10 px-4 text-sm",
-    lg: "h-12 px-6 text-base",
-  },
-}
-
-export function getButtonClasses(
-  variant: keyof typeof buttonStyles.variants = "primary",
-  size: keyof typeof buttonStyles.sizes = "md",
-  className?: string,
-) {
-  return `${buttonStyles.base} ${buttonStyles.variants[variant]} ${buttonStyles.sizes[size]}${className ? ` ${className}` : ""}`
-}
-
-export function Button(props: ButtonProps) {
-  const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
+export function Button(props: ComponentProps<"button"> & ButtonProps) {
+  const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
   return (
-    <KobalteButton
+    <Kobalte
+      {...rest}
+      data-size={split.size || "sm"}
+      data-variant={split.variant || "secondary"}
+      class="inline-flex items-center justify-center rounded-md cursor-pointer font-medium transition-colors
+             min-w-0 whitespace-nowrap truncate
+             data-[size=sm]:h-6 data-[size=sm]:pl-2 data-[size=sm]:text-xs
+             data-[size=md]:h-8 data-[size=md]:pl-3 data-[size=md]:text-sm
+             data-[size=lg]:h-10 data-[size=lg]:pl-4 data-[size=lg]:text-base
+             data-[variant=primary]:bg-primary data-[variant=primary]:text-background 
+             data-[variant=primary]:hover:bg-secondary data-[variant=primary]:focus-visible:ring-primary
+             data-[variant=secondary]:bg-background-element data-[variant=secondary]:text-text
+             data-[variant=secondary]:hover:bg-background-element data-[variant=secondary]:focus-visible:ring-secondary
+             data-[variant=ghost]:text-text data-[variant=ghost]:hover:bg-background-panel data-[variant=ghost]:focus-visible:ring-border-active
+             focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-transparent 
+             disabled:pointer-events-none disabled:opacity-50"
       classList={{
-        ...(local.classList ?? {}),
-        [buttonStyles.base]: true,
-        [buttonStyles.variants[local.variant || "primary"]]: true,
-        [buttonStyles.sizes[local.size || "md"]]: true,
-        [local.class ?? ""]: !!local.class,
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
       }}
-      {...others}
-    />
+    >
+      {props.children}
+    </Kobalte>
   )
 }

+ 3 - 5
packages/app/src/ui/link.tsx

@@ -1,15 +1,13 @@
 import { A } from "@solidjs/router"
 import { splitProps } from "solid-js"
 import type { ComponentProps } from "solid-js"
-import { getButtonClasses } from "./button"
 
 export interface LinkProps extends ComponentProps<typeof A> {
-  variant?: "primary" | "secondary" | "outline" | "ghost"
+  variant?: "primary" | "secondary" | "ghost"
   size?: "sm" | "md" | "lg"
 }
 
 export function Link(props: LinkProps) {
-  const [local, others] = splitProps(props, ["variant", "size", "class"])
-  const classes = local.variant ? getButtonClasses(local.variant, local.size, local.class) : local.class
-  return <A class={classes} {...others} />
+  const [, others] = splitProps(props, ["variant", "size", "class"])
+  return <A {...others} />
 }

+ 1 - 1
packages/app/src/ui/tooltip.tsx

@@ -34,7 +34,7 @@ export function Tooltip(props: TooltipProps) {
       <KobalteTooltip.Portal>
         <KobalteTooltip.Content
           classList={{
-            "z-50 max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
+            "z-[1000] max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
             "text-xs font-medium text-text shadow-md pointer-events-none!": true,
             "transition-all duration-150 ease-out": true,
             "transform-gpu transform-origin-[var(--kb-tooltip-content-transform-origin)]": true,